feat(admin): enhance resource and management workflows
This commit is contained in:
parent
4d872de302
commit
d6cafb98b1
@ -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"
|
||||
|
||||
@ -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®ions=lalu:9,lalu:11");
|
||||
fetchSocialBiMaster.mockResolvedValue({
|
||||
|
||||
@ -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 })));
|
||||
|
||||
|
||||
@ -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="日期区间">
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -69,6 +69,11 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
||||
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
||||
return selected.length ? selected : available;
|
||||
}, [filters.apps, master]);
|
||||
// 运营人员筛选只接受 master 目录中的正整数 ID;URL 中的脏值不能直接进入后端统计查询。
|
||||
const operatorUserId = useMemo(() => {
|
||||
const value = String(filters.operator || "");
|
||||
return /^\d+$/.test(value) ? Number(value) : undefined;
|
||||
}, [filters.operator]);
|
||||
const overviewRequestScopes = useMemo(
|
||||
() => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []),
|
||||
[appCodes, filters.regions, master?.regions]
|
||||
@ -87,7 +92,9 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
||||
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||
: Promise.resolve(null);
|
||||
// KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。
|
||||
const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null);
|
||||
const kpiRequest = includeKpi
|
||||
? fetchSocialBiKpi({ appCodes, endMs, operatorUserId, startMs, statTz: SOCIAL_BI_TZ })
|
||||
: Promise.resolve(null);
|
||||
// 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。
|
||||
const overviewRequest = overviewRequestScopes.length
|
||||
? Promise.all(
|
||||
@ -144,7 +151,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
||||
setErrors(nextErrors);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]);
|
||||
}, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, operatorUserId, overviewRequestScopes, range, refreshToken]);
|
||||
|
||||
const loadRequirements = useCallback(
|
||||
({ newUserType, payerType, section, userRole } = {}) => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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ı aşıyor. Mevcut sonuçlar eksik.",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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" /> },
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" }));
|
||||
|
||||
|
||||
@ -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} /> }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} /> },
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
69
external-admin/src/theme.js
Normal file
69
external-admin/src/theme.js
Normal 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
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 }>(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -2,11 +2,17 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||
import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { getAppUser } from "@/features/app-users/api";
|
||||
import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx";
|
||||
|
||||
// AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。
|
||||
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() }));
|
||||
vi.mock("@/features/app-users/api", () => ({
|
||||
getAppUser: vi.fn(),
|
||||
issueAppUserAccessToken: vi.fn(),
|
||||
revokeAppUserResource: vi.fn(),
|
||||
}));
|
||||
|
||||
test("full user detail route loads the URL user and returns to its source list", async () => {
|
||||
vi.mocked(getAppUser).mockResolvedValue({
|
||||
@ -26,19 +32,23 @@ test("full user detail route loads the URL user and returns to its source list",
|
||||
|
||||
render(
|
||||
<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>,
|
||||
);
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -328,6 +328,7 @@ export interface ResourceGroupPayload {
|
||||
}
|
||||
|
||||
export interface ResourceShopItemPayload {
|
||||
coinPrice: number;
|
||||
durationDays: number;
|
||||
effectiveFromMs: number;
|
||||
effectiveToMs: number;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 }))}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
57
src/shared/api/generated/schema.d.ts
vendored
57
src/shared/api/generated/schema.d.ts
vendored
@ -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"];
|
||||
};
|
||||
|
||||
462
src/shared/ui/Mp4Preview.jsx
Normal file
462
src/shared/ui/Mp4Preview.jsx
Normal 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);
|
||||
}
|
||||
`;
|
||||
88
src/shared/ui/Mp4Preview.module.css
Normal file
88
src/shared/ui/Mp4Preview.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 创建内部 Canvas,span 会在 setVideoItem 时稳定失败。 */}
|
||||
<div ref={containerRef} className={styles.playerSurface} />
|
||||
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user