feat(admin): enhance resource and management workflows

This commit is contained in:
zhx 2026-07-15 19:34:11 +08:00
parent 4d872de302
commit d6cafb98b1
53 changed files with 2654 additions and 388 deletions

View File

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

View File

@ -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(<DatabiApp />);
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&regions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({

View File

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

View File

@ -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 }) {
<div className="sbi-topbar-row">
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
{viewKey === "team" ? <OperatorSelect data={data} filters={filters} updateFilter={updateFilter} /> : null}
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
</div>
@ -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 (
<Autocomplete
className="sbi-operator-filter"
disableClearable
disablePortal
filterOptions={filterOperatorOptions}
getOptionLabel={operatorIdentityLabel}
isOptionEqualToValue={(option, value) => 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 (
<li {...optionProps} className={`${optionProps.className || ""} sbi-operator-option`} key={key || operator.user_id}>
<strong>{operatorIdentityLabel(operator)}</strong>
{details ? <small>{details}</small> : null}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="运营人员"
slotProps={{
...params.slotProps,
htmlInput: {
...params.slotProps?.htmlInput,
"aria-label": "运营人员筛选"
}
}}
/>
)}
/>
);
}
function DatePresetControl({ filters, updateFilter }) {
return (
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">

View File

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

View File

@ -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 目录中的正整数 IDURL 中的脏值不能直接进入后端统计查询。
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 } = {}) => {

View File

@ -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 ? (
<KpiSkeleton />
) : (
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无充值数据</strong>
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
</div>
</section>
);
if (isLoading && !kpi) {
return <KpiSkeleton />;
}
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 (
<div className="sbi-kpi-view">
@ -110,7 +105,7 @@ export function TeamKpiView() {
<div className="sbi-card-header">
<strong>充值排行</strong>
<small>
{kpi.period_month} · {formatCount(operatorCount)} 名运营
{kpi?.period_month || "--"} · {formatCount(operatorCount)} 名运营
</small>
<div className="sbi-card-toolbar">
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
@ -129,6 +124,7 @@ export function TeamKpiView() {
</div>
</div>
</div>
<KpiSummary operator={selectedOperator} operatorCount={operatorCount} summary={summary} />
<div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? (
<PersonTable rows={personRows} />
@ -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 (
<div aria-label="充值金额汇总" className="sbi-kpi-summary">
<div className="sbi-kpi-summary-person">
<span>{operator ? "当前人员" : "当前范围"}</span>
<strong>{operator ? operatorIdentity(operator) : "全部运营人员"}</strong>
<small>{details}</small>
</div>
<SummaryAmount label="区间总金额" value={summary.range_recharge_usd_minor} />
<SummaryAmount label="当月总金额" value={summary.month_recharge_usd_minor} />
</div>
);
}
function operatorIdentity(operator) {
return operator?.name || operator?.account || `#${operator?.user_id}`;
}
function SummaryAmount({ label, value }) {
return (
<div className="sbi-kpi-summary-amount">
<span>{label}</span>
<strong>{formatMoneyMinor(value)}</strong>
</div>
);
}
function KpiSkeleton() {
return (
<div aria-busy="true" className="sbi-kpi-view">
@ -184,6 +210,7 @@ function OperatorAppTable({ rows }) {
</tr>
</thead>
<tbody>
{!rows.length ? <EmptyTableRow colSpan={6} /> : null}
{rows.map((row, index) => {
const item = row.item;
return (
@ -230,6 +257,7 @@ function PersonTable({ rows }) {
</tr>
</thead>
<tbody>
{!rows.length ? <EmptyTableRow colSpan={6} /> : null}
{rows.map((row, index) => {
const item = row.item;
return (
@ -279,6 +307,7 @@ function TeamTable({ rows }) {
</tr>
</thead>
<tbody>
{!rows.length ? <EmptyTableRow colSpan={5} /> : null}
{rows.map((row, index) => (
<tr key={row.team}>
<td className="is-left">
@ -295,6 +324,14 @@ function TeamTable({ rows }) {
);
}
function EmptyTableRow({ colSpan }) {
return (
<tr>
<td className="sbi-kpi-empty-cell" colSpan={colSpan}>当前无数据</td>
</tr>
);
}
function OperatorCell({ item }) {
return (
<div className="sbi-kpi-person">

View File

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

View File

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

View File

@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#0d0d11" />
<title>External Admin</title>
<style>html { background: #0d0d11; }</style>
</head>
<body>
<div id="external-admin-root"></div>

View File

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

View File

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

View File

@ -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",
@ -325,13 +328,13 @@ const zhCN = {
"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.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,10 +350,10 @@ const ar = {
...en,
"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,10 +372,10 @@ const tr = {
...en,
"app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil",
"common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı",
"auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": ıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster",
"nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim",
"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ııyor. Mevcut sonuçlar eksik.",

View File

@ -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";
@ -38,7 +37,7 @@ import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
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" },
@ -52,27 +51,43 @@ 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));
useExternalSessionBrand(session, t("app.title"));
const handleLogout = async () => {
setSheetOpen(false);
await logout();
navigate("/login", { replace: true });
};
const drawer = (
const accountCard = (
<>
<Avatar className="external-user-avatar">{String(session?.account || "A").slice(0, 1).toUpperCase()}</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={650} noWrap sx={{ lineHeight: 1.25 }}>{session?.account}</Typography>
<Typography color="text.secondary" noWrap variant="caption">{session?.appName || session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}</Typography>
</Box>
</>
);
const sidebar = (
<Box className="external-sidebar" sx={{ width: drawerWidth }}>
<Box className="external-logo">
<AppIdentity
@ -92,9 +107,8 @@ export function ExternalAdminLayout() {
<ListItemButton
component={NavLink}
key={item.path}
selected={location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)}
selected={isItemActive(item, location.pathname)}
to={item.path}
onClick={() => setDrawerOpen(false)}
>
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
<ListItemText primary={t(item.labelKey)} />
@ -102,47 +116,88 @@ export function ExternalAdminLayout() {
);
})}
</List>
<Box className="external-sidebar-footer">
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>{accountCard}</Stack>
<Button color="inherit" onClick={() => navigate("/change-password")} startIcon={<KeyOutlined />}>{t("auth.changePassword")}</Button>
<Button onClick={handleLogout} startIcon={<LogoutOutlined />} variant="outlined">{t("auth.logout")}</Button>
</Box>
</Box>
);
return (
<Box className="external-shell">
{/* Physical margins/anchors are written for LTR; the RTL Emotion cache flips them for Arabic. */}
<AppBar
className="external-header"
color="inherit"
elevation={0}
position="fixed"
sx={{
ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0,
mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0,
ml: { md: `${drawerWidth}px` },
width: { md: `calc(100% - ${drawerWidth}px)` }
}}
>
<Toolbar>
{mobile ? <IconButton aria-label={t("nav.expand")} onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
<Typography component="h2" fontWeight={700} noWrap>{current ? t(current.labelKey) : t("app.title")}</Typography>
<Typography className="external-header-title" component="h2" noWrap>{mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")}</Typography>
<Box flex={1} />
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<LanguageSwitcher compact={mobile} />
<Avatar className="external-user-avatar">{String(session?.account || "A").slice(0, 1).toUpperCase()}</Avatar>
{!mobile ? (
<Box>
<Typography fontWeight={650} sx={{ lineHeight: 1.2 }}>{session?.account}</Typography>
<Typography color="text.secondary" variant="caption">{session?.appCode}{session?.displayName ? ` · ${session.displayName}` : ""}</Typography>
</Box>
) : null}
<Tooltip title={t("auth.logout")}>
<Button aria-label={t("auth.logout")} color="inherit" onClick={handleLogout} startIcon={<LogoutOutlined />}>{mobile ? "" : t("auth.logout")}</Button>
</Tooltip>
{!mobile ? accountCard : null}
</Stack>
</Toolbar>
</AppBar>
<Drawer anchor={direction === "rtl" ? "right" : "left"} open={drawerOpen} variant="temporary" onClose={() => setDrawerOpen(false)} ModalProps={{ keepMounted: true }} sx={{ display: { md: "none" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Drawer anchor={direction === "rtl" ? "right" : "left"} open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{drawer}</Drawer>
<Box className="external-main" component="main" sx={{ ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0, mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0 }}>
<Toolbar />
<Drawer anchor="left" open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{sidebar}</Drawer>
<Box className="external-main" component="main" sx={{ ml: { md: `${drawerWidth}px` } }}>
<Toolbar className="external-toolbar-spacer" />
<Outlet />
</Box>
{/* Shown below the md breakpoint via index.css; sx display rules would lose to the stylesheet cascade. */}
<Box className="external-tabbar" component="nav">
{tabs.map((item) => {
const Icon = item.icon;
return (
<NavLink className={isItemActive(item, location.pathname) ? "external-tab is-active" : "external-tab"} key={item.path} to={item.path}>
<Icon />
<span className="external-tab-label">{t(item.labelKey)}</span>
</NavLink>
);
})}
<button aria-label={t("nav.more")} className={moreActive ? "external-tab is-active" : "external-tab"} type="button" onClick={() => setSheetOpen(true)}>
<GridViewOutlined />
<span className="external-tab-label">{t("nav.more")}</span>
</button>
</Box>
<Drawer
anchor="bottom"
open={sheetOpen}
slotProps={{ paper: { className: "external-sheet" } }}
sx={{ display: { md: "none" } }}
onClose={() => setSheetOpen(false)}
>
<Box className="external-sheet-handle" />
<Box className="external-sheet-account">{accountCard}</Box>
<Box className="external-sheet-grid">
{visibleNavigation.map((item) => {
const Icon = item.icon;
return (
<NavLink
className={isItemActive(item, location.pathname) ? "external-sheet-item is-active" : "external-sheet-item"}
key={item.path}
to={item.path}
onClick={() => setSheetOpen(false)}
>
<Icon />
<span>{t(item.labelKey)}</span>
</NavLink>
);
})}
</Box>
<Divider sx={{ my: 1.5 }} />
<Stack direction="row" spacing={1.5}>
<Button color="inherit" fullWidth startIcon={<KeyOutlined />} onClick={() => { setSheetOpen(false); navigate("/change-password"); }}>{t("auth.changePassword")}</Button>
<Button fullWidth startIcon={<LogoutOutlined />} variant="outlined" onClick={handleLogout}>{t("auth.logout")}</Button>
</Stack>
</Drawer>
</Box>
);
}

View File

@ -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";
@ -34,17 +36,22 @@ describe("ExternalAdminLayout RTL", () => {
};
});
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", () => {
@ -53,7 +60,8 @@ 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)
@ -65,7 +73,7 @@ describe("ExternalAdminLayout RTL", () => {
expect(screen.queryByText("إدارة HYApp الخارجية")).not.toBeInTheDocument();
expect(document.title).toBe("Fami");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/fami.png");
expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0);
expect(screen.getAllByText("الرئيسية").length).toBeGreaterThan(0);
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
view.unmount();
@ -95,15 +103,19 @@ describe("ExternalAdminLayout RTL", () => {
});
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(
<ExternalI18nProvider>
<MemoryRouter initialEntries={["/overview"]}>
<Routes>
<Route element={<ExternalAdminLayout />}>
<Route element={<div>overview content</div>} path="/overview" />
</Route>
</Routes>
</MemoryRouter>
</ExternalI18nProvider>
<CacheProvider value={externalEmotionCache("rtl")}>
<ExternalI18nProvider>
<MemoryRouter initialEntries={["/overview"]}>
<Routes>
<Route element={<ExternalAdminLayout />}>
<Route element={<div>overview content</div>} path="/overview" />
</Route>
</Routes>
</MemoryRouter>
</ExternalI18nProvider>
</CacheProvider>
);
}

View File

@ -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 (
<CacheProvider value={externalEmotionCache(direction)}>

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
{ key: "banner", label: t("banners.banner"), render: (item) => <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>{item.coverUrl ? <Box alt={item.description} className="external-banner-thumb" component="img" src={item.coverUrl} /> : null}<Box><Typography fontWeight={650}>{item.description || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}</Typography></Box></Stack> },
{ key: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" },
{ key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") },
{ key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder },

View File

@ -43,7 +43,7 @@ export default function BansPage() {
};
const columns = useMemo(() => [
{ key: "user", label: t("common.user"), render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "user", label: t("common.user"), render: (user) => <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" },
{ key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" },
{ key: "status", label: t("common.status"), render: () => <StatusChip status="banned" /> },

View File

@ -69,7 +69,7 @@ export default function ChangePasswordPage() {
showLogoFallback={false}
size="large"
/>
<Typography component="h1" variant="h5">{t("auth.changePassword")}</Typography>
<Typography className="external-login-title" component="h1" variant="h5">{t("auth.changePassword")}</Typography>
<Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography>
</Stack>
<Stack component="form" spacing={2} onSubmit={submit}>

View File

@ -91,8 +91,8 @@ export default function GrantsPage() {
{canGrant ? (
<Box className="external-form-panel">
<Typography fontWeight={700} gutterBottom>{t("grants.availableResources")}</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack alignItems="center" direction="row" key={resource.id} spacing={0.75}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded">R</Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{resourcesState.data.items.slice(0, 12).map((resource) => <Stack direction="row" key={resource.id} spacing={0.75} sx={{ alignItems: "center" }}><Avatar src={resource.coverUrl} sx={{ height: 28, width: 28 }} variant="rounded">R</Avatar><Typography variant="body2">{resource.name}</Typography></Stack>)}
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">{t("common.noData")}</Typography> : null}
</Stack>
</Box>

View File

@ -66,7 +66,7 @@ export default function LoginPage() {
<CardContent>
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
<Typography component="h1" variant="h5">{t("app.title")}</Typography>
<Typography className="external-login-title" component="h1" variant="h5">{t("app.title")}</Typography>
</Stack>
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
{error ? <Alert severity="error">{error}</Alert> : null}

View File

@ -31,7 +31,7 @@ describe("LoginPage", () => {
expect(document.title).toBe("External Admin");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.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");
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
expect(container.querySelector("img")).toBeNull();
await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
@ -45,7 +45,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" }));

View File

@ -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) => <StatusChip status={item.status} /> }
] : [
{ key: "identity", label: t("organization.member"), render: (item) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""}</Typography></Box></Stack> },
{ key: "identity", label: t("organization.member"), render: (item) => <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}><Avatar src={item.avatar}>{(item.name || item.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{item.name || item.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id || "-"}{item.userId && item.userId !== item.id ? ` / ${t("organization.userReference", { id: item.userId })}` : ""}</Typography></Box></Stack> },
{ key: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" },
{ key: "country", label: t("organization.country"), render: (item) => item.country || "-" },
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> }

View File

@ -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 (
<ExternalPage title={t("nav.overview")}>
<Card className="external-session-card" variant="outlined">
<CardContent>
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
<Box><Typography color="text.secondary" variant="caption">{t("overview.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.account")}</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.permissions")}</Typography><Typography fontWeight={700}>{EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
</Stack>
</CardContent>
</Card>
<Box className="external-hero">
<Typography className="external-hero-eyebrow">{t("overview.welcome")}</Typography>
<Typography className="external-hero-title" component="h2">{session.appName || session.appCode}</Typography>
<Box className="external-hero-meta">
<Box><Typography color="text.secondary" variant="caption">{t("overview.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.account")}</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
<Box><Typography color="text.secondary" variant="caption">{t("overview.permissions")}</Typography><Typography fontWeight={700}>{enabledCount} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
</Box>
</Box>
<Typography className="external-section-title" component="h3">{t("overview.features")}</Typography>
<Box className="external-capability-grid">
{EXTERNAL_CAPABILITIES.map((item) => {
const enabled = hasExternalCapability(session, item.code);
const Icon = CAPABILITY_ICONS[item.code] || ListAltOutlined;
const content = (
<CardContent>
<Stack alignItems="flex-start" spacing={1.5}>
{enabled ? <CheckCircleOutlineOutlined color="success" /> : <LockOutlined color="disabled" />}
<Stack spacing={1.5} sx={{ alignItems: "flex-start" }}>
<Box className="external-capability-icon"><Icon fontSize="small" /></Box>
<Typography fontWeight={700}>{t(item.labelKey)}</Typography>
<Chip color={enabled ? "success" : "default"} label={enabled ? t("overview.enabled") : t("overview.notEnabled")} size="small" variant="outlined" />
</Stack>

View File

@ -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) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={item.coverUrl} variant="rounded">R</Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
{ key: "room", label: t("rooms.room"), render: (item) => <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}><Avatar src={item.coverUrl} variant="rounded">R</Avatar><Box><Typography fontWeight={650}>{item.title || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {item.id}</Typography></Box></Stack> },
{ key: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" },
{ key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") },
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> },

View File

@ -49,12 +49,12 @@ export default function UsersPage() {
};
const columns = useMemo(() => [
{ key: "user", label: t("common.user"), render: (user) => <Stack alignItems="center" direction="row" spacing={1}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "user", label: t("common.user"), render: (user) => <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}><Avatar src={user.avatar}>{(user.username || "-").slice(0, 1)}</Avatar><Box><Typography fontWeight={650}>{user.username || "-"}</Typography><Typography color="text.secondary" variant="caption">ID {user.id}</Typography></Box></Stack> },
{ key: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" },
{ key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` },
{ key: "level", label: t("users.level"), render: (user) => t("users.levelSummary", { wealth: user.wealthLevel, charm: user.charmLevel, vip: user.vipLevel }) },
{ key: "status", label: t("common.status"), render: (user) => <StatusChip status={user.banned ? "banned" : user.status} /> },
{ key: "actions", label: t("common.actions"), render: (user) => <Stack direction="row" flexWrap="wrap" gap={0.5}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>{t("users.edit")}</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>{t("users.grantLevel")}</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>{t("users.ban")}</Button> : null}</Stack> }
{ key: "actions", label: t("common.actions"), render: (user) => <Stack direction="row" sx={{ flexWrap: "wrap", gap: 0.5 }}>{canEdit ? <Button onClick={() => setDialog({ type: "edit", user })} startIcon={<EditOutlined />}>{t("users.edit")}</Button> : null}{canGrantLevel ? <Button onClick={() => setDialog({ type: "level", user })} startIcon={<TrendingUpOutlined />}>{t("users.grantLevel")}</Button> : null}{canBan && !user.banned ? <Button color="error" onClick={() => setDialog({ type: "ban", user })} startIcon={<GppBadOutlined />}>{t("users.ban")}</Button> : null}</Stack> }
], [canBan, canEdit, canGrantLevel, t]);
return (

View File

@ -21,11 +21,11 @@ import { translateExternalError } from "../i18n/errors.js";
export function ExternalPage({ actions, children, title }) {
return (
<Box className="external-page">
<Stack alignItems={{ md: "center" }} direction={{ xs: "column", md: "row" }} justifyContent="space-between" spacing={2}>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ alignItems: { md: "center" }, justifyContent: "space-between" }}>
<Typography component="h1" variant="h5">
{title}
</Typography>
{actions ? <Stack direction="row" flexWrap="wrap" gap={1}>{actions}</Stack> : null}
{actions ? <Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>{actions}</Stack> : null}
</Stack>
{children}
</Box>
@ -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 (
<Stack alignItems="center" direction="row" justifyContent="space-between" spacing={1}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography color="text.secondary" variant="body2">{t("pagination.total", { count: total })}</Typography>
<Stack direction="row" spacing={1}>
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">{t("pagination.previous")}</Button>

View File

@ -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,29 +107,63 @@ 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 {
@ -36,8 +171,11 @@ body {
}
.external-change-password-brand {
color: var(--primary-strong);
font-family: var(--font-display);
font-size: 18px;
font-weight: 750;
letter-spacing: 0.03em;
}
.external-full-state {
@ -49,34 +187,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 {
@ -85,36 +250,47 @@ body {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
padding: 14px 20px;
}
.external-sidebar-brand {
color: var(--primary-strong);
font-family: var(--font-display);
font-size: 16px;
font-weight: 750;
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 {
@ -122,10 +298,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;
@ -133,12 +457,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);
}
@ -152,32 +481,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;
}
@ -186,11 +489,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;
@ -211,31 +526,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,
@ -243,7 +678,7 @@ body {
width: 104px;
height: 68px;
object-fit: cover;
border-radius: 6px;
border-radius: 8px;
background: var(--bg-card-strong);
}
@ -251,7 +686,7 @@ body {
width: 88px;
height: 50px;
object-fit: cover;
border-radius: 6px;
border-radius: 8px;
background: var(--bg-card-strong);
}
@ -267,23 +702,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) {

View File

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

View File

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

View File

@ -48,6 +48,29 @@ export async function getAppUser(userId: EntityId): Promise<AppUserDto> {
return normalizeAppUser(data);
}
export interface RevokeAppUserResourcePayload {
reason: string;
}
// 详情页按 entitlement 撤回单个素材,不能复用 grant 级撤销;后者会把同一资源组内的其它素材一并回收。
export function revokeAppUserResource(
userId: EntityId,
entitlementId: EntityId,
payload: RevokeAppUserResourcePayload,
): Promise<unknown> {
const endpoint = API_ENDPOINTS.revokeAppUserResource;
return apiRequest<unknown, RevokeAppUserResourcePayload>(
apiEndpointPath(API_OPERATIONS.revokeAppUserResource, {
entitlement_id: entitlementId,
user_id: userId,
}),
{
body: payload,
method: endpoint.method,
},
);
}
export async function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
const endpoint = API_ENDPOINTS.appListLoginLogs;
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(

View File

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

View File

@ -60,12 +60,13 @@ export function AppUserDetailProvider({ children }) {
open={Boolean(detailUser)}
user={detailUser}
onClose={closeAppUserDetail}
onReloadUser={() => openAppUserDetail(detailUser)}
/>
</AppUserDetailContext.Provider>
);
}
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}
/>
</SideDrawer>
);

View File

@ -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(
<ToastProvider>
<MemoryRouter initialEntries={["/app/users?status=active"]}>
<AppUserDetailProvider>
<DetailTrigger />
</AppUserDetailProvider>
</MemoryRouter>
<ConfirmProvider>
<MemoryRouter initialEntries={["/app/users?status=active"]}>
<AppUserDetailProvider>
<DetailTrigger />
</AppUserDetailProvider>
</MemoryRouter>
</ConfirmProvider>
</ToastProvider>,
);
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(
<ToastProvider>
<ConfirmProvider>
<MemoryRouter initialEntries={["/app/users"]}>
<AppUserDetailProvider>
<DetailTrigger />
</AppUserDetailProvider>
</MemoryRouter>
</ConfirmProvider>
</ToastProvider>,
);
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 (

View File

@ -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" ? <OverviewPanel user={user} /> : null}
{activeTab === "assets" ? <AssetsPanel balances={balances} user={user} /> : null}
{activeTab === "resources" ? (
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
<ResourcesPanel
equippedResources={equippedResources}
onReloadUser={onReloadUser}
resources={resources}
user={user}
/>
) : null}
{activeTab === "status" ? <StatusPanel user={user} /> : null}
{activeTab === "device" ? <DevicePanel user={user} /> : 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 (
<>
<DetailSection title={`当前佩戴 (${equippedResources.length})`}>
<ResourceList resources={equippedResources} />
<ResourceList
resources={equippedResources}
revokingEntitlementId={revokingEntitlementId}
onRevokeResource={revokeResource}
/>
</DetailSection>
<DetailSection title={`资源资产 (${resources.length})`}>
<ResourceList resources={resources} />
<ResourceList
resources={resources}
revokingEntitlementId={revokingEntitlementId}
onRevokeResource={revokeResource}
/>
</DetailSection>
</>
);
@ -437,7 +497,7 @@ function AssetBalanceList({ balances }) {
);
}
function ResourceList({ resources }) {
function ResourceList({ onRevokeResource, resources, revokingEntitlementId }) {
if (!resources.length) {
return <div className={styles.emptyInline}>当前无数据</div>;
}
@ -448,7 +508,7 @@ function ResourceList({ resources }) {
<ResourceThumb resource={resource} />
<div className={styles.resourceBody}>
<div className={styles.resourceTitle}>
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
<span>{resourceDisplayName(resource)}</span>
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
</div>
<div className={styles.resourceMeta}>
@ -456,12 +516,28 @@ function ResourceList({ resources }) {
</div>
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
</div>
{resource.entitlementId ? (
<Button
aria-label={`撤回素材 ${resourceDisplayName(resource)}`}
className={styles.resourceRevokeButton}
disabled={Boolean(revokingEntitlementId)}
variant="danger"
onClick={() => onRevokeResource(resource)}
>
<UndoOutlined fontSize="small" />
{revokingEntitlementId === resource.entitlementId ? "撤回中..." : "撤回"}
</Button>
) : null}
</li>
))}
</ul>
);
}
function resourceDisplayName(resource) {
return resource?.name || resource?.resourceCode || `资源 ${resource?.resourceId || "-"}`;
}
function ResourceThumb({ resource }) {
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
if (!imageUrl) {

View File

@ -47,7 +47,7 @@ export function AppUserDetailPage() {
</div>
<div className={styles.detailPageContent}>
<DataState error={pageError} loading={false} onRetry={normalizedUserId ? reload : undefined}>
{data ? <AppUserDetailView mode="page" user={data} /> : null}
{data ? <AppUserDetailView mode="page" user={data} onReloadUser={reload} /> : null}
</DataState>
</div>
</div>

View File

@ -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 issueAppUserAccessTokenmock
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(
<QueryProvider>
<MemoryRouter
initialEntries={[
{
pathname: "/app/users/10001",
state: { from: "/app/users?status=active" },
},
]}
>
<Routes>
<Route path="/app/users/:userId" element={<AppUserDetailPage />} />
<Route path="/app/users" element={<LocationValue />} />
</Routes>
</MemoryRouter>
<ToastProvider>
<ConfirmProvider>
<MemoryRouter
initialEntries={[
{
pathname: "/app/users/10001",
state: { from: "/app/users?status=active" },
},
]}
>
<Routes>
<Route path="/app/users/:userId" element={<AppUserDetailPage />} />
<Route path="/app/users" element={<LocationValue />} />
</Routes>
</MemoryRouter>
</ConfirmProvider>
</ToastProvider>
</QueryProvider>,
);

View File

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

View File

@ -328,6 +328,7 @@ export interface ResourceGroupPayload {
}
export interface ResourceShopItemPayload {
coinPrice: number;
durationDays: number;
effectiveFromMs: number;
effectiveToMs: number;

View File

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

View File

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

View File

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

View File

@ -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 (
<AdminFormDialog
@ -1055,7 +1059,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
/>
<TextField
disabled={disabled}
disabled={disabled || animationUploading}
label="资源类型"
required
select
@ -1229,26 +1233,31 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
<AdminFormSection title="素材">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField
key={open ? "resource-preview-open" : "resource-preview-closed"}
disabled={disabled || uploadDisabled}
kind="file"
label="资源封面"
showSourceActions
value={form.previewUrl}
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
onUploadingChange={setPreviewUploading}
/>
<UploadField
key={open ? "resource-animation-open" : "resource-animation-closed"}
disabled={disabled || uploadDisabled}
kind="file"
label="动效素材"
mp4Layout={mp4Layout}
showSourceActions
value={form.animationUrl}
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
onChange={handleAnimationChange}
onFileSelected={handleAnimationSelected}
onUploadingChange={setAnimationUploading}
/>
</AdminFormFieldGrid>
{mp4Layout ? (
<Mp4AlphaLayoutEditor
disabled={disabled}
disabled={disabled || animationUploading}
metadataJson={form.metadataJson}
onChange={(metadataJson) => setForm((previous) => ({ ...previous, metadataJson }))}
/>

View File

@ -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 }) {
取消
</Button>
<Button
disabled={disabled || page.resourceOptionsLoading || page.selectedDrafts.length === 0}
disabled={
disabled ||
page.resourceOptionsLoading ||
Boolean(page.resourceOptionsError) ||
page.selectedDrafts.length === 0
}
type="submit"
variant="primary"
>
@ -212,7 +222,7 @@ function ResourceShopDrawer({ page }) {
as="form"
open={page.drawerOpen}
title="添加售卖资源"
width="wide"
width="detail"
onClose={saving ? undefined : page.closeShopDrawer}
onSubmit={page.submitShopItems}
>
@ -236,21 +246,12 @@ function ResourceShopDrawer({ page }) {
</MenuItem>
))}
</TextField>
<TextField
label="批量售卖天数"
select
size="small"
value={page.batchDurationDays}
onChange={(event) => page.applyBatchDuration(event.target.value)}
>
{resourceShopDurationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
</div>
<DataState error={null} loading={page.resourceOptionsLoading} onRetry={page.openShopDrawer}>
<DataState
error={page.resourceOptionsError}
loading={page.resourceOptionsLoading}
onRetry={page.reloadShopDrawerOptions}
>
{page.filteredResourceOptions.length > 0 ? (
<div className={styles.shopSelectionList}>
{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 (
<div className={styles.shopSelectionRow}>
<Checkbox
checked={selected}
disabled={disabled}
inputProps={{ "aria-label": selected ? "取消选择资源" : "选择资源" }}
disabled={disabled || !canUnselectGroup}
inputProps={{
"aria-label":
!canUnselectGroup
? "资源已有售卖规格,请在列表启停"
: selected
? "取消选择资源"
: "选择资源",
}}
onChange={() => page.toggleDraftResource(resource)}
/>
<ResourceIdentity resource={resource} />
<span className={styles.shopBasePrice}>{resourcePriceLabel(resource)}</span>
<span className={styles.shopBasePrice}>资源价 {resourcePriceLabel(resource)}</span>
{draftGroup ? (
<div className={styles.shopSpecList}>
{resourceShopDurationOptions.map(([durationDays, label]) => (
<ResourceShopSpecRow
disabled={disabled}
draft={draftGroup.specsByDuration?.[durationDays]}
durationDays={durationDays}
key={durationDays}
label={label}
page={page}
resource={resource}
/>
))}
</div>
) : null}
</div>
);
}
function ResourceShopSpecRow({ disabled, draft, durationDays, label, page, resource }) {
const resourceId = String(resource.resourceId);
const updateDraft = (patch) => page.updateDraftSpec(resourceId, durationDays, patch);
return (
<div className={styles.shopSpecRow}>
<label className={styles.shopSpecToggle}>
<Checkbox
checked={Boolean(draft)}
disabled={disabled || (draft && !canUnselectResourceShopDraftSpec(draft))}
inputProps={{
"aria-label": draft?.shopItemId ? `${label}售卖规格已保存,请在列表启停` : `${label}售卖规格`,
}}
onChange={() => page.toggleDraftDuration(resource, durationDays)}
/>
<span>{label}</span>
</label>
{draft ? (
<div className={styles.shopSelectionControls}>
<>
<TextField
disabled={disabled}
label="售卖天数"
select
inputProps={{ min: 1, step: 1 }}
label="售卖价格"
required
size="small"
value={draft.durationDays}
onChange={(event) => page.updateDraftItem(resourceId, { durationDays: event.target.value })}
>
{resourceShopDurationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
InputLabelProps={{ shrink: true }}
disabled={disabled}
label="开始时间"
size="small"
type="datetime-local"
value={draft.effectiveFrom}
onChange={(event) => page.updateDraftItem(resourceId, { effectiveFrom: event.target.value })}
type="number"
value={draft.coinPrice}
onChange={(event) => updateDraft({ coinPrice: event.target.value })}
/>
<TextField
InputLabelProps={{ shrink: true }}
<TimeRangeFilter
className={styles.shopSpecTimeRange}
disabled={disabled}
label="结束时间"
size="small"
type="datetime-local"
value={draft.effectiveTo}
onChange={(event) => page.updateDraftItem(resourceId, { effectiveTo: event.target.value })}
label="生效时间"
value={{ endMs: draft.effectiveToMs, startMs: draft.effectiveFromMs }}
onChange={(range) =>
updateDraft({ effectiveFromMs: range.startMs, effectiveToMs: range.endMs })
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label="排序"
size="small"
type="number"
value={draft.sortOrder}
onChange={(event) => page.updateDraftItem(resourceId, { sortOrder: event.target.value })}
onChange={(event) => updateDraft({ sortOrder: event.target.value })}
/>
</div>
) : null}
</>
) : (
<span className={styles.shopSpecDisabled}>未售卖</span>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -314,6 +314,7 @@ export const API_OPERATIONS = {
retryRedPacketRefund: "retryRedPacketRefund",
retryRoomRpsSettlement: "retryRoomRpsSettlement",
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
revokeAppUserResource: "revokeAppUserResource",
revokeResourceGrant: "revokeResourceGrant",
savePolicyTemplate: "savePolicyTemplate",
search: "search",
@ -2503,6 +2504,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;
@ -11425,6 +11441,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;
@ -11468,7 +11505,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"];
};

View File

@ -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 (
<NativeMp4Preview autoPlay={autoPlay && !reducedMotion} controls={controls} expanded={expanded} src={src} />
);
}
return (
<AlphaMp4Preview
autoPlay={autoPlay && !reducedMotion}
controls={controls}
expanded={expanded}
layoutKey={layoutKey}
src={src}
/>
);
}
function NativeMp4Preview({ autoPlay, controls, expanded, src }) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [src]);
return (
<span className={[styles.root, expanded ? styles.expanded : ""].filter(Boolean).join(" ")}>
{failed ? (
<span className={styles.error}>MP4 预览失败</span>
) : (
<video
autoPlay={autoPlay}
className={styles.media}
controls={controls}
loop={autoPlay}
muted={autoPlay}
playsInline
preload={autoPlay ? "auto" : "metadata"}
src={src}
onError={() => setFailed(true)}
/>
)}
</span>
);
}
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 (
<span className={[styles.root, expanded ? styles.expanded : ""].filter(Boolean).join(" ")}>
<canvas ref={canvasRef} className={styles.media} />
<video ref={videoRef} aria-hidden="true" className={styles.sourceVideo} tabIndex={-1} />
{failed ? <span className={styles.error}>透明 MP4 预览失败</span> : null}
{controls && !failed ? (
<button
aria-label={playing ? "暂停透明 MP4" : "播放透明 MP4"}
className={styles.playbackControl}
type="button"
onClick={togglePlayback}
>
{playing ? <PauseRounded fontSize="small" /> : <PlayArrowRounded fontSize="small" />}
{playing ? "暂停" : "播放"}
</button>
) : null}
</span>
);
}
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);
}
`;

View File

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

View File

@ -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}
>
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
<AssetPreview
assetKind={assetKind}
isImage={isImage}
mp4Layout={previewMp4Layout}
src={source}
/>
{uploading ? (
<span className={styles.overlay}>
<CircularProgress color="inherit" size={18} />
@ -249,6 +295,7 @@ export function UploadField({
</div>
<VideoPreviewDialog
label={label}
mp4Layout={previewMp4Layout}
open={videoPreviewOpen}
src={canPreviewVideo ? source : ""}
onClose={() => setVideoPreviewOpen(false)}
@ -265,15 +312,13 @@ export function UploadField({
);
}
function VideoPreviewDialog({ label, onClose, open, src }) {
function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) {
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
<DialogTitle>{label} MP4</DialogTitle>
<DialogContent>
<div className={styles.videoPreviewFrame}>
{src ? (
<video className={styles.videoPreview} controls playsInline preload="metadata" src={src} />
) : null}
{src ? <Mp4Preview autoPlay controls expanded layout={mp4Layout} src={src} /> : null}
</div>
</DialogContent>
<DialogActions>
@ -283,7 +328,7 @@ function VideoPreviewDialog({ label, onClose, open, src }) {
);
}
function AssetPreview({ assetKind, isImage, src }) {
function AssetPreview({ assetKind, isImage, mp4Layout, src }) {
if (!src) {
return (
<span className={styles.empty}>
@ -297,6 +342,9 @@ function AssetPreview({ assetKind, isImage, src }) {
if (assetKind === "pag") {
return <PAGAssetPreview src={src} />;
}
if (assetKind === "mp4") {
return <Mp4Preview autoPlay layout={mp4Layout} src={src} />;
}
if (assetKind === "image" || isImage) {
return <RasterAssetPreview src={src} />;
}
@ -390,7 +438,8 @@ function SVGAAssetPreview({ src }) {
return (
<span className={styles.player}>
<span ref={containerRef} className={styles.playerSurface} />
{/* svgaplayerweb 2.3.2 只会为 HTMLDivElement 创建内部 Canvasspan 会在 setVideoItem 时稳定失败。 */}
<div ref={containerRef} className={styles.playerSurface} />
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
</span>
);

View File

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