feat(admin): enhance resource and management workflows
This commit is contained in:
parent
9f272257b8
commit
52c54f6914
@ -5018,6 +5018,47 @@
|
|||||||
"x-permissions": ["resource-grant:revoke"]
|
"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": {
|
"/admin/resource-grants/resource": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "grantResource",
|
"operationId": "grantResource",
|
||||||
@ -5055,6 +5096,50 @@
|
|||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "upsertResourceShopItems",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
"$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);
|
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 () => {
|
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");
|
window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11");
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
fetchSocialBiMaster.mockResolvedValue({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
fetchFilterOptions,
|
fetchFilterOptions,
|
||||||
fetchSocialBiFilterOptions,
|
fetchSocialBiFilterOptions,
|
||||||
|
fetchSocialBiKpi,
|
||||||
fetchSocialBiOverview,
|
fetchSocialBiOverview,
|
||||||
fetchSocialBiRequirements,
|
fetchSocialBiRequirements,
|
||||||
fetchStatisticsOverview,
|
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");
|
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 () => {
|
test("omits app scope headers and query params for all databi apps", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));
|
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 RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import RepeatOutlined from "@mui/icons-material/RepeatOutlined";
|
import RepeatOutlined from "@mui/icons-material/RepeatOutlined";
|
||||||
import TableChartOutlined from "@mui/icons-material/TableChartOutlined";
|
import TableChartOutlined from "@mui/icons-material/TableChartOutlined";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
ALL,
|
ALL,
|
||||||
DATE_PRESETS,
|
DATE_PRESETS,
|
||||||
@ -40,6 +42,7 @@ const VIEWS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const SocialBiContext = createContext(null);
|
const SocialBiContext = createContext(null);
|
||||||
|
const EMPTY_OPERATORS = [];
|
||||||
|
|
||||||
export function useSocialBi() {
|
export function useSocialBi() {
|
||||||
const context = useContext(SocialBiContext);
|
const context = useContext(SocialBiContext);
|
||||||
@ -160,6 +163,7 @@ function TopBar({ data, filters, updateFilter, viewKey }) {
|
|||||||
<div className="sbi-topbar-row">
|
<div className="sbi-topbar-row">
|
||||||
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||||||
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
<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>
|
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
||||||
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
||||||
</div>
|
</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 }) {
|
function DatePresetControl({ filters, updateFilter }) {
|
||||||
return (
|
return (
|
||||||
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
|
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度,
|
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度,
|
||||||
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
||||||
|
|
||||||
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||||
@ -27,6 +27,7 @@ export function createDefaultFilters() {
|
|||||||
customEnd: "",
|
customEnd: "",
|
||||||
customStart: "",
|
customStart: "",
|
||||||
granularity: "day",
|
granularity: "day",
|
||||||
|
operator: ALL,
|
||||||
preset: "yesterday",
|
preset: "yesterday",
|
||||||
regions: [ALL]
|
regions: [ALL]
|
||||||
};
|
};
|
||||||
@ -116,7 +117,7 @@ export function bucketDay(statDay, granularity) {
|
|||||||
return statDay;
|
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) {
|
export function readStateFromURL(location = window.location) {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@ -133,6 +134,9 @@ export function readStateFromURL(location = window.location) {
|
|||||||
if (params.get("regions")) {
|
if (params.get("regions")) {
|
||||||
filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean);
|
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"))) {
|
if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) {
|
||||||
filters.granularity = 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)) {
|
if (filters.regions.length && !filters.regions.includes(ALL)) {
|
||||||
params.set("regions", filters.regions.join(","));
|
params.set("regions", filters.regions.join(","));
|
||||||
}
|
}
|
||||||
|
if (filters.operator !== ALL) {
|
||||||
|
params.set("operator", filters.operator);
|
||||||
|
}
|
||||||
if (filters.granularity !== "day") {
|
if (filters.granularity !== "day") {
|
||||||
params.set("granularity", filters.granularity);
|
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));
|
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
||||||
return selected.length ? selected : available;
|
return selected.length ? selected : available;
|
||||||
}, [filters.apps, master]);
|
}, [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(
|
const overviewRequestScopes = useMemo(
|
||||||
() => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []),
|
() => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []),
|
||||||
[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 })
|
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
// KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。
|
// 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 逐日渠道。
|
// 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。
|
||||||
const overviewRequest = overviewRequestScopes.length
|
const overviewRequest = overviewRequestScopes.length
|
||||||
? Promise.all(
|
? Promise.all(
|
||||||
@ -144,7 +151,7 @@ export function useSocialBiData(filters, { includeFunnel = false, includeKpi = f
|
|||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
setIsLoading(false);
|
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(
|
const loadRequirements = useCallback(
|
||||||
({ newUserType, payerType, section, userRole } = {}) => {
|
({ newUserType, payerType, section, userRole } = {}) => {
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
||||||
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
||||||
|
import { ALL } from "../state.js";
|
||||||
import "./team-kpi-view.css";
|
import "./team-kpi-view.css";
|
||||||
|
|
||||||
const DIMENSIONS = [
|
const DIMENSIONS = [
|
||||||
@ -40,7 +41,7 @@ function operatorCountOf(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeamKpiView() {
|
export function TeamKpiView() {
|
||||||
const { isLoading, kpi } = useSocialBi();
|
const { filters, isLoading, kpi, master } = useSocialBi();
|
||||||
const [dimension, setDimension] = useState("person");
|
const [dimension, setDimension] = useState("person");
|
||||||
|
|
||||||
const items = useMemo(() => kpi?.items || [], [kpi]);
|
const items = useMemo(() => kpi?.items || [], [kpi]);
|
||||||
@ -88,21 +89,15 @@ export function TeamKpiView() {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
if (!items.length) {
|
if (isLoading && !kpi) {
|
||||||
return isLoading ? (
|
return <KpiSkeleton />;
|
||||||
<KpiSkeleton />
|
|
||||||
) : (
|
|
||||||
<section className="sbi-card">
|
|
||||||
<div className="sbi-empty">
|
|
||||||
<strong>暂无充值数据</strong>
|
|
||||||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = kpi.summary || {};
|
const summary = kpi?.summary || {};
|
||||||
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
|
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 (
|
return (
|
||||||
<div className="sbi-kpi-view">
|
<div className="sbi-kpi-view">
|
||||||
@ -110,7 +105,7 @@ export function TeamKpiView() {
|
|||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>充值排行</strong>
|
<strong>充值排行</strong>
|
||||||
<small>
|
<small>
|
||||||
{kpi.period_month} · {formatCount(operatorCount)} 名运营
|
{kpi?.period_month || "--"} · {formatCount(operatorCount)} 名运营
|
||||||
</small>
|
</small>
|
||||||
<div className="sbi-card-toolbar">
|
<div className="sbi-card-toolbar">
|
||||||
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
||||||
@ -129,6 +124,7 @@ export function TeamKpiView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<KpiSummary operator={selectedOperator} operatorCount={operatorCount} summary={summary} />
|
||||||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
||||||
{dimension === "person" ? (
|
{dimension === "person" ? (
|
||||||
<PersonTable rows={personRows} />
|
<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() {
|
function KpiSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div aria-busy="true" className="sbi-kpi-view">
|
<div aria-busy="true" className="sbi-kpi-view">
|
||||||
@ -184,6 +210,7 @@ function OperatorAppTable({ rows }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{!rows.length ? <EmptyTableRow colSpan={6} /> : null}
|
||||||
{rows.map((row, index) => {
|
{rows.map((row, index) => {
|
||||||
const item = row.item;
|
const item = row.item;
|
||||||
return (
|
return (
|
||||||
@ -230,6 +257,7 @@ function PersonTable({ rows }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{!rows.length ? <EmptyTableRow colSpan={6} /> : null}
|
||||||
{rows.map((row, index) => {
|
{rows.map((row, index) => {
|
||||||
const item = row.item;
|
const item = row.item;
|
||||||
return (
|
return (
|
||||||
@ -279,6 +307,7 @@ function TeamTable({ rows }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{!rows.length ? <EmptyTableRow colSpan={5} /> : null}
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<tr key={row.team}>
|
<tr key={row.team}>
|
||||||
<td className="is-left">
|
<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 }) {
|
function OperatorCell({ item }) {
|
||||||
return (
|
return (
|
||||||
<div className="sbi-kpi-person">
|
<div className="sbi-kpi-person">
|
||||||
|
|||||||
@ -8,10 +8,57 @@
|
|||||||
|
|
||||||
.sbi-kpi-board-scroll {
|
.sbi-kpi-board-scroll {
|
||||||
max-height: 620px;
|
max-height: 620px;
|
||||||
margin-top: 12px;
|
|
||||||
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
|
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 {
|
.sbi-kpi-rank {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@ -67,3 +114,9 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px;
|
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;
|
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 {
|
.sbi-range-label {
|
||||||
color: var(--sbi-text-3);
|
color: var(--sbi-text-3);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<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>HYApp External Admin</title>
|
<title>Admin System</title>
|
||||||
|
<style>html { background: #0d0d11; }</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="external-admin-root"></div>
|
<div id="external-admin-root"></div>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => {
|
|||||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
|
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent("en");
|
expect(screen.getByTestId("locale")).toHaveTextContent("en");
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("HYApp External Admin");
|
expect(screen.getByTestId("title")).toHaveTextContent("Admin System");
|
||||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||||
});
|
});
|
||||||
@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => {
|
|||||||
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "ar" }));
|
await user.click(screen.getByRole("button", { name: "ar" }));
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("إدارة HYApp الخارجية");
|
expect(screen.getByTestId("title")).toHaveTextContent("نظام الإدارة");
|
||||||
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||||
expect(document.body).toHaveAttribute("dir", "rtl");
|
expect(document.body).toHaveAttribute("dir", "rtl");
|
||||||
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
|
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "tr" }));
|
await user.click(screen.getByRole("button", { name: "tr" }));
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("HYApp Harici Yönetim");
|
expect(screen.getByTestId("title")).toHaveTextContent("Yönetim Sistemi");
|
||||||
expect(document.documentElement).toHaveAttribute("lang", "tr");
|
expect(document.documentElement).toHaveAttribute("lang", "tr");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "zh-CN" }));
|
await user.click(screen.getByRole("button", { name: "zh-CN" }));
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台");
|
expect(screen.getByTestId("title")).toHaveTextContent("管理系统");
|
||||||
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
||||||
|
|
||||||
firstRender.unmount();
|
firstRender.unmount();
|
||||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
|
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("HYApp 外管后台");
|
expect(screen.getByTestId("title")).toHaveTextContent("管理系统");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import createCache from "@emotion/cache";
|
import createCache from "@emotion/cache";
|
||||||
import rtlPlugin from "@mui/stylis-plugin-rtl";
|
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 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) {
|
export function externalEmotionCache(direction) {
|
||||||
return direction === "rtl" ? externalRtlCache : externalLtrCache;
|
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 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 { describe, expect, test } from "vitest";
|
||||||
import { externalEmotionCache } from "./emotionCache.js";
|
import { externalEmotionCache } from "./emotionCache.js";
|
||||||
|
|
||||||
@ -13,7 +17,7 @@ describe("external admin RTL Emotion cache", () => {
|
|||||||
test("mirrors physical MUI-style declarations for Arabic", () => {
|
test("mirrors physical MUI-style declarations for Arabic", () => {
|
||||||
const css = serialize(
|
const css = serialize(
|
||||||
compile(".control{margin-left:8px;padding-right:12px;text-align:left}"),
|
compile(".control{margin-left:8px;padding-right:12px;text-align:left}"),
|
||||||
middleware([prefixer, rtlPlugin, stringify])
|
middleware([rtlPlugin, stringify])
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(css).toContain("margin-right:8px");
|
expect(css).toContain("margin-right:8px");
|
||||||
@ -21,4 +25,15 @@ describe("external admin RTL Emotion cache", () => {
|
|||||||
expect(css).toContain("text-align:right");
|
expect(css).toContain("text-align:right");
|
||||||
expect(css).not.toContain("margin-left:8px");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const en = {
|
const en = {
|
||||||
"app.title": "HYApp External Admin",
|
"app.title": "Admin System",
|
||||||
"app.shortTitle": "HYApp External Admin",
|
"app.shortTitle": "Admin System",
|
||||||
"language.label": "Language",
|
"language.label": "Language",
|
||||||
"common.actions": "Actions",
|
"common.actions": "Actions",
|
||||||
"common.active": "Active",
|
"common.active": "Active",
|
||||||
@ -35,9 +35,9 @@ const en = {
|
|||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.status": "Status",
|
"common.status": "Status",
|
||||||
"common.user": "User",
|
"common.user": "User",
|
||||||
"auth.account": "External admin account",
|
"auth.account": "Account",
|
||||||
"auth.changePassword": "Change external admin password",
|
"auth.changePassword": "Change password",
|
||||||
"auth.checkingSession": "Checking external admin session",
|
"auth.checkingSession": "Checking your session",
|
||||||
"auth.confirmNewPassword": "Confirm new password",
|
"auth.confirmNewPassword": "Confirm new password",
|
||||||
"auth.currentPassword": "Current password",
|
"auth.currentPassword": "Current password",
|
||||||
"auth.fillAccountPassword": "Enter your account and password",
|
"auth.fillAccountPassword": "Enter your account and password",
|
||||||
@ -48,7 +48,7 @@ const en = {
|
|||||||
"auth.loginFailed": "Sign-in failed",
|
"auth.loginFailed": "Sign-in failed",
|
||||||
"auth.logout": "Sign out",
|
"auth.logout": "Sign out",
|
||||||
"auth.newPassword": "New password",
|
"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.password": "Password",
|
||||||
"auth.passwordChangedFailed": "Failed to change password",
|
"auth.passwordChangedFailed": "Failed to change password",
|
||||||
"auth.passwordMismatch": "The new passwords do not match",
|
"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.passwordMinError": "The new password must be at least 8 characters",
|
||||||
"auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace",
|
"auth.passwordWhitespace": "The new password cannot be empty or contain only whitespace",
|
||||||
"auth.recheck": "Check again",
|
"auth.recheck": "Check again",
|
||||||
"auth.returnOverview": "Return to permission overview",
|
"auth.returnOverview": "Back to Home",
|
||||||
"auth.savePassword": "Save password",
|
"auth.savePassword": "Save password",
|
||||||
"auth.sessionFailed": "Session verification failed",
|
"auth.sessionFailed": "Session verification failed",
|
||||||
"auth.showPassword": "Show password",
|
"auth.showPassword": "Show password",
|
||||||
"nav.expand": "Open navigation",
|
"nav.expand": "Open navigation",
|
||||||
"nav.overview": "Permission overview",
|
"nav.more": "More",
|
||||||
|
"nav.overview": "Home",
|
||||||
"nav.users": "User management",
|
"nav.users": "User management",
|
||||||
"nav.bans": "Ban management",
|
"nav.bans": "Ban management",
|
||||||
"nav.hosts": "Hosts",
|
"nav.hosts": "Hosts",
|
||||||
@ -94,8 +95,10 @@ const en = {
|
|||||||
"capability.userLevelGrant": "Grant wealth/VIP levels",
|
"capability.userLevelGrant": "Grant wealth/VIP levels",
|
||||||
"capability.teamView": "My team",
|
"capability.teamView": "My team",
|
||||||
"overview.currentApp": "Current App",
|
"overview.currentApp": "Current App",
|
||||||
"overview.account": "External admin account",
|
"overview.account": "Account",
|
||||||
"overview.permissions": "Permissions enabled",
|
"overview.permissions": "Permissions enabled",
|
||||||
|
"overview.welcome": "Welcome back",
|
||||||
|
"overview.features": "Features",
|
||||||
"overview.enabled": "Enabled",
|
"overview.enabled": "Enabled",
|
||||||
"overview.notEnabled": "Not enabled",
|
"overview.notEnabled": "Not enabled",
|
||||||
"users.title": "User management",
|
"users.title": "User management",
|
||||||
@ -323,17 +326,17 @@ const en = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zhCN = {
|
const zhCN = {
|
||||||
"app.title": "HYApp 外管后台",
|
"app.title": "管理系统",
|
||||||
"app.shortTitle": "HYApp 外管",
|
"app.shortTitle": "管理系统",
|
||||||
"language.label": "语言",
|
"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": "用户",
|
"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": "显示密码",
|
"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": "我的团队",
|
"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": "我的团队",
|
"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": "下发原因",
|
"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} 的封禁?",
|
"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": "全部",
|
"rooms.title": "房间管理", "rooms.room": "房间", "rooms.owner": "房主", "rooms.visibleRegion": "可见区域", "rooms.search": "房间 ID / 名称 / 房主", "rooms.updated": "房间信息已更新", "rooms.updateFailed": "房间更新失败", "rooms.editTitle": "编辑房间", "rooms.background": "房间背景图", "rooms.name": "房间名称", "rooms.description": "房间介绍", "rooms.visibleRegionId": "可见区域 ID", "rooms.allRegions": "全部",
|
||||||
"grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放",
|
"grants.title": "资源与靓号", "grants.grantResource": "发放道具", "grants.grantPrettyId": "下发靓号", "grants.availableResources": "可发放资源", "grants.targetUser": "目标用户", "grants.resource": "资源", "grants.quantity": "数量", "grants.resourceGranted": "特权道具已发放", "grants.prettyIdGranted": "靓号已下发", "grants.failed": "发放失败", "grants.resourceTitle": "特权道具发放", "grants.prettyIdTitle": "靓号下发", "grants.target": "用户 ID / 短 ID(靓号)", "grants.currentTarget": "用户 ID / 当前短 ID", "grants.prettyId": "下发靓号", "grants.validDays": "有效天数", "grants.permanentHint": "填 0 表示永久有效", "grants.reason": "发放原因", "grants.notice": "提交前会校验当前 App 内的用户,并使用服务端返回的用户 ID 执行发放。", "grants.confirm": "确认发放",
|
||||||
"banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间",
|
"banners.title": "Banner 管理", "banners.banner": "Banner", "banners.type": "类型", "banners.platform": "平台", "banners.sort": "排序", "banners.create": "创建 Banner", "banners.created": "Banner 已创建", "banners.createFailed": "Banner 创建失败", "banners.image": "Banner 图片", "banners.roomImage": "房间内小屏图", "banners.description": "描述", "banners.jumpType": "跳转类型", "banners.h5Param": "参数(H5 链接)", "banners.appParam": "参数(APP 公共跳转 JSON)", "banners.displayScope": "展示范围", "banners.scopeHome": "首页", "banners.scopeRoom": "房间", "banners.scopeRecharge": "充值", "banners.scopeMe": "我的", "banners.regionId": "区域 ID", "banners.countryCode": "国家码", "banners.countryCodeHint": "2-3 位字母,留空为全部", "banners.schedule": "投放时间",
|
||||||
@ -347,12 +350,12 @@ const zhCN = {
|
|||||||
|
|
||||||
const ar = {
|
const ar = {
|
||||||
...en,
|
...en,
|
||||||
"app.title": "إدارة HYApp الخارجية", "app.shortTitle": "إدارة HYApp الخارجية", "language.label": "اللغة",
|
"app.title": "نظام الإدارة", "app.shortTitle": "نظام الإدارة", "language.label": "اللغة",
|
||||||
"common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم",
|
"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": "إظهار كلمة المرور",
|
"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": "فريقي",
|
"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": "فريقي",
|
"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": "سبب المنح",
|
"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}؟",
|
"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": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.",
|
"organization.title": "المؤسسة", "organization.agencies": "قائمة الوكالات", "organization.bds": "قائمة BD", "organization.bdManagers": "قائمة مديري BD", "organization.hosts": "قائمة المضيفين", "organization.managers": "قائمة المديرين", "organization.superAdmins": "قائمة المشرفين العامين", "organization.team": "فريقي", "organization.agency": "الوكالة", "organization.owner": "المالك", "organization.parentBd": "BD الأعلى", "organization.member": "العضو", "organization.country": "الدولة / المنطقة", "organization.userReference": "المستخدم {id}", "organization.agencyReference": "الوكالة {id}", "organization.teamSearch": "معرّف الوكالة / معرّف المالك / معرّف BD الأعلى", "organization.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى قائمة المؤسسة هذه.", "organization.bdLeaderCount": "قادة BD: {count}", "organization.bdCount": "BD: {count}", "organization.agencyCount": "الوكالات: {count}", "organization.truncated": "يتجاوز حجم الفريق حد الواجهة. النتائج الحالية غير مكتملة.",
|
||||||
@ -369,12 +372,12 @@ const ar = {
|
|||||||
|
|
||||||
const tr = {
|
const tr = {
|
||||||
...en,
|
...en,
|
||||||
"app.title": "HYApp Harici Yönetim", "app.shortTitle": "HYApp Harici Yönetim", "language.label": "Dil",
|
"app.title": "Yönetim Sistemi", "app.shortTitle": "Yönetim Sistemi", "language.label": "Dil",
|
||||||
"common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı",
|
"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",
|
"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.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",
|
"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",
|
"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",
|
"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ı?",
|
"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.",
|
"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 ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||||
import BadgeOutlined from "@mui/icons-material/BadgeOutlined";
|
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 GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||||
|
import HomeOutlined from "@mui/icons-material/HomeOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
||||||
|
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
||||||
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
|
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
|
||||||
import MenuOutlined from "@mui/icons-material/MenuOutlined";
|
|
||||||
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
|
||||||
import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined";
|
import PeopleOutlineOutlined from "@mui/icons-material/PeopleOutlineOutlined";
|
||||||
import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined";
|
import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined";
|
||||||
@ -16,14 +17,12 @@ import Box from "@mui/material/Box";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
@ -36,7 +35,7 @@ import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
|||||||
|
|
||||||
const drawerWidth = 248;
|
const drawerWidth = 248;
|
||||||
const navigation = [
|
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: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: ["user-ban:list", "user:unban"], icon: PersonOffOutlined, labelKey: "nav.bans", path: "/bans" },
|
||||||
{ capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" },
|
{ capabilities: ["host:list"], icon: BadgeOutlined, labelKey: "nav.hosts", path: "/organization/hosts" },
|
||||||
@ -50,31 +49,46 @@ const navigation = [
|
|||||||
{ capabilities: ["team:view"], icon: GroupsOutlined, labelKey: "nav.team", path: "/organization/team" }
|
{ 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() {
|
export function ExternalAdminLayout() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const mobile = useMediaQuery(theme.breakpoints.down("md"));
|
const mobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const { logout, session } = useExternalAuth();
|
const { logout, session } = useExternalAuth();
|
||||||
const { direction, t } = useExternalI18n();
|
const { t } = useExternalI18n();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const visibleNavigation = useMemo(
|
const visibleNavigation = useMemo(
|
||||||
() => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)),
|
() => navigation.filter((item) => !item.capabilities || hasAnyExternalCapability(session, item.capabilities)),
|
||||||
[session]
|
[session]
|
||||||
);
|
);
|
||||||
const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
|
const current = [...visibleNavigation].reverse().find((item) => isItemActive(item, location.pathname));
|
||||||
|
// The H5 tab bar keeps three primary destinations reachable with a thumb; everything else lives in the "More" sheet.
|
||||||
|
const tabs = visibleNavigation.slice(0, 3);
|
||||||
|
const moreActive = !tabs.some((item) => isItemActive(item, location.pathname));
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
setSheetOpen(false);
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login", { replace: true });
|
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-sidebar" sx={{ width: drawerWidth }}>
|
||||||
<Box className="external-logo">
|
<Box className="external-logo">
|
||||||
<Box className="external-logo-mark">HY</Box>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography fontWeight={750}>{t("app.shortTitle")}</Typography>
|
<Typography className="external-logo-name" fontWeight={650}>{t("app.shortTitle")}</Typography>
|
||||||
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
|
<Typography color="text.secondary" variant="caption">{session?.appName || session?.appCode}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@ -86,9 +100,8 @@ export function ExternalAdminLayout() {
|
|||||||
<ListItemButton
|
<ListItemButton
|
||||||
component={NavLink}
|
component={NavLink}
|
||||||
key={item.path}
|
key={item.path}
|
||||||
selected={location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)}
|
selected={isItemActive(item, location.pathname)}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => setDrawerOpen(false)}
|
|
||||||
>
|
>
|
||||||
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
|
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
|
||||||
<ListItemText primary={t(item.labelKey)} />
|
<ListItemText primary={t(item.labelKey)} />
|
||||||
@ -96,47 +109,88 @@ export function ExternalAdminLayout() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="external-shell">
|
<Box className="external-shell">
|
||||||
|
{/* Physical margins/anchors are written for LTR; the RTL Emotion cache flips them for Arabic. */}
|
||||||
<AppBar
|
<AppBar
|
||||||
className="external-header"
|
className="external-header"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
elevation={0}
|
elevation={0}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
sx={{
|
sx={{
|
||||||
ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0,
|
ml: { md: `${drawerWidth}px` },
|
||||||
mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0,
|
|
||||||
width: { md: `calc(100% - ${drawerWidth}px)` }
|
width: { md: `calc(100% - ${drawerWidth}px)` }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
{mobile ? <IconButton aria-label={t("nav.expand")} onClick={() => setDrawerOpen(true)}><MenuOutlined /></IconButton> : null}
|
<Typography className="external-header-title" component="h2" noWrap>{mobile && !current ? t("app.shortTitle") : t(current?.labelKey || "app.shortTitle")}</Typography>
|
||||||
<Typography component="h2" fontWeight={700} noWrap>{current ? t(current.labelKey) : t("app.title")}</Typography>
|
|
||||||
<Box flex={1} />
|
<Box flex={1} />
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
<LanguageSwitcher compact={mobile} />
|
<LanguageSwitcher compact={mobile} />
|
||||||
<Avatar className="external-user-avatar">{String(session?.account || "A").slice(0, 1).toUpperCase()}</Avatar>
|
{!mobile ? accountCard : null}
|
||||||
{!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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</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="left" open variant="permanent" sx={{ display: { xs: "none", md: "block" }, "& .MuiDrawer-paper": { width: drawerWidth } }}>{sidebar}</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: { md: `${drawerWidth}px` } }}>
|
||||||
<Box className="external-main" component="main" sx={{ ml: direction === "ltr" ? { md: `${drawerWidth}px` } : 0, mr: direction === "rtl" ? { md: `${drawerWidth}px` } : 0 }}>
|
<Toolbar className="external-toolbar-spacer" />
|
||||||
<Toolbar />
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</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>
|
</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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { externalEmotionCache } from "../i18n/emotionCache.js";
|
||||||
import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx";
|
import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx";
|
||||||
import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js";
|
import { EXTERNAL_LOCALE_STORAGE_KEY } from "../i18n/messages.js";
|
||||||
import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx";
|
import { ExternalAdminLayout } from "./ExternalAdminLayout.jsx";
|
||||||
@ -26,17 +28,22 @@ describe("ExternalAdminLayout RTL", () => {
|
|||||||
viewport.mobile = false;
|
viewport.mobile = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opens the mobile navigation from the right in Arabic", async () => {
|
test("shows the H5 tab bar and opens the more sheet in Arabic", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
viewport.mobile = true;
|
viewport.mobile = true;
|
||||||
renderLayout();
|
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"));
|
await user.click(within(tabbar).getByRole("button", { name: "المزيد" }));
|
||||||
expect(openTemporaryDrawer).toBeInTheDocument();
|
|
||||||
expect(openTemporaryDrawer.parentElement).not.toHaveStyle({ visibility: "hidden" });
|
const sheet = document.querySelector(".MuiDrawer-paper.external-sheet");
|
||||||
expect(screen.getAllByRole("link", { name: "ملخص الصلاحيات" }).length).toBeGreaterThan(0);
|
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", () => {
|
test("places the desktop navigation drawer on the right for Arabic", () => {
|
||||||
@ -45,28 +52,33 @@ describe("ExternalAdminLayout RTL", () => {
|
|||||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||||
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
expect(document.documentElement).toHaveAttribute("lang", "ar");
|
||||||
const permanentDrawer = document.querySelectorAll(".MuiDrawer-paper")[0];
|
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)
|
const drawerRule = Array.from(document.styleSheets)
|
||||||
.flatMap((sheet) => Array.from(sheet.cssRules || []))
|
.flatMap((sheet) => Array.from(sheet.cssRules || []))
|
||||||
.map((rule) => rule.cssText)
|
.map((rule) => rule.cssText)
|
||||||
.find((cssText) => cssText.includes(`.${generatedClass}`));
|
.find((cssText) => cssText.includes(`.${generatedClass}`));
|
||||||
expect(drawerRule).toContain("right: 0px");
|
expect(drawerRule).toContain("right: 0px");
|
||||||
expect(drawerRule).toContain("border-left:");
|
expect(drawerRule).toContain("border-left:");
|
||||||
expect(screen.getAllByText("ملخص الصلاحيات").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("الرئيسية").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
|
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderLayout() {
|
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(
|
return render(
|
||||||
<ExternalI18nProvider>
|
<CacheProvider value={externalEmotionCache("rtl")}>
|
||||||
<MemoryRouter initialEntries={["/overview"]}>
|
<ExternalI18nProvider>
|
||||||
<Routes>
|
<MemoryRouter initialEntries={["/overview"]}>
|
||||||
<Route element={<ExternalAdminLayout />}>
|
<Routes>
|
||||||
<Route element={<div>overview content</div>} path="/overview" />
|
<Route element={<ExternalAdminLayout />}>
|
||||||
</Route>
|
<Route element={<div>overview content</div>} path="/overview" />
|
||||||
</Routes>
|
</Route>
|
||||||
</MemoryRouter>
|
</Routes>
|
||||||
</ExternalI18nProvider>
|
</MemoryRouter>
|
||||||
|
</ExternalI18nProvider>
|
||||||
|
</CacheProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { CacheProvider } from "@emotion/react";
|
import { CacheProvider } from "@emotion/react";
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
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 React, { useMemo } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { theme } from "@/theme.js";
|
|
||||||
import { ExternalAdminApp } from "./ExternalAdminApp.jsx";
|
import { ExternalAdminApp } from "./ExternalAdminApp.jsx";
|
||||||
|
import { createExternalTheme } from "./theme.js";
|
||||||
import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx";
|
import { ExternalAuthProvider } from "./auth/ExternalAuthProvider.jsx";
|
||||||
import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx";
|
import { ExternalI18nProvider, useExternalI18n } from "./i18n/ExternalI18nProvider.jsx";
|
||||||
import { externalEmotionCache } from "./i18n/emotionCache.js";
|
import { externalEmotionCache } from "./i18n/emotionCache.js";
|
||||||
@ -25,10 +25,7 @@ createRoot(document.getElementById("external-admin-root")).render(
|
|||||||
function ExternalThemeRoot() {
|
function ExternalThemeRoot() {
|
||||||
const { direction } = useExternalI18n();
|
const { direction } = useExternalI18n();
|
||||||
// A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction.
|
// A direction-aware MUI theme keeps inputs, menus and dialog controls consistent with the Arabic document direction.
|
||||||
const externalTheme = useMemo(() => createTheme(theme, {
|
const externalTheme = useMemo(() => createExternalTheme(direction), [direction]);
|
||||||
direction,
|
|
||||||
typography: { fontFamily: 'Inter, "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif' }
|
|
||||||
}), [direction]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CacheProvider value={externalEmotionCache(direction)}>
|
<CacheProvider value={externalEmotionCache(direction)}>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default function BannersPage() {
|
|||||||
const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]);
|
const load = useCallback(() => listBanners({ page, page_size: 20 }), [page]);
|
||||||
const { data, error, loading, reload } = usePagedResource(load);
|
const { data, error, loading, reload } = usePagedResource(load);
|
||||||
const columns = useMemo(() => [
|
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: "type", label: t("banners.type"), render: (item) => item.bannerType || "-" },
|
||||||
{ key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") },
|
{ key: "platform", label: t("banners.platform"), render: (item) => item.platform || t("common.all") },
|
||||||
{ key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder },
|
{ key: "sort", label: t("banners.sort"), render: (item) => item.sortOrder },
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default function BansPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
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: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" },
|
||||||
{ key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" },
|
{ key: "reason", label: t("bans.reason"), render: (user) => user.reason || "-" },
|
||||||
{ key: "status", label: t("common.status"), render: () => <StatusChip status="banned" /> },
|
{ key: "status", label: t("common.status"), render: () => <StatusChip status="banned" /> },
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import Avatar from "@mui/material/Avatar";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
@ -59,7 +57,7 @@ export default function ChangePasswordPage() {
|
|||||||
<Card className="external-login-card" elevation={0}>
|
<Card className="external-login-card" elevation={0}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
||||||
<Stack spacing={1.5} sx={{ alignItems: "center" }}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">{t("auth.changePassword")}</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography></Stack>
|
<Stack spacing={1.5} sx={{ alignItems: "center" }}><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}>
|
<Stack component="form" spacing={2} onSubmit={submit}>
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
|
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
|
||||||
|
|||||||
@ -91,8 +91,8 @@ export default function GrantsPage() {
|
|||||||
{canGrant ? (
|
{canGrant ? (
|
||||||
<Box className="external-form-panel">
|
<Box className="external-form-panel">
|
||||||
<Typography fontWeight={700} gutterBottom>{t("grants.availableResources")}</Typography>
|
<Typography fontWeight={700} gutterBottom>{t("grants.availableResources")}</Typography>
|
||||||
<Stack direction="row" flexWrap="wrap" gap={1}>
|
<Stack direction="row" sx={{ 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>)}
|
{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}
|
{!resourcesState.loading && !resourcesState.data.items.length ? <Typography color="text.secondary">{t("common.noData")}</Typography> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import LockOutlined from "@mui/icons-material/LockOutlined";
|
|
||||||
import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
|
import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
|
||||||
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import Avatar from "@mui/material/Avatar";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
@ -62,8 +60,7 @@ export default function LoginPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
||||||
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
|
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
<Avatar className="external-login-avatar"><LockOutlined /></Avatar>
|
<Typography className="external-login-title" component="h1" variant="h5">{t("app.title")}</Typography>
|
||||||
<Typography component="h1" variant="h5">{t("app.title")}</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
|
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
|
|||||||
@ -20,9 +20,9 @@ describe("LoginPage", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { container } = renderLogin();
|
const { container } = renderLogin();
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "HYApp External Admin" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Admin System" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument();
|
||||||
await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager");
|
await user.type(screen.getByRole("textbox", { name: "Account" }), "fami-manager");
|
||||||
await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
|
await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
|
||||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ describe("LoginPage", () => {
|
|||||||
auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 });
|
auth.login.mockRejectedValueOnce({ code: 40100, message: "账号已禁用", status: 401 });
|
||||||
const { container } = renderLogin();
|
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.type(container.querySelector('input[autocomplete="current-password"]'), "bad-password");
|
||||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
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: "parent", label: t("organization.parentBd"), render: (item) => item.parentBdUserId || "-" },
|
||||||
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> }
|
{ 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: "prettyId", label: t("users.prettyId"), render: (item) => item.prettyId || "-" },
|
||||||
{ key: "country", label: t("organization.country"), render: (item) => item.country || "-" },
|
{ key: "country", label: t("organization.country"), render: (item) => item.country || "-" },
|
||||||
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> }
|
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> }
|
||||||
|
|||||||
@ -1,5 +1,21 @@
|
|||||||
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||||
import LockOutlined from "@mui/icons-material/LockOutlined";
|
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 Box from "@mui/material/Box";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import CardActionArea from "@mui/material/CardActionArea";
|
import CardActionArea from "@mui/material/CardActionArea";
|
||||||
@ -13,28 +29,54 @@ import { EXTERNAL_CAPABILITIES, hasExternalCapability } from "../config/capabili
|
|||||||
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
||||||
import { ExternalPage } from "../shared/PageComponents.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() {
|
export default function OverviewPage() {
|
||||||
const { session } = useExternalAuth();
|
const { session } = useExternalAuth();
|
||||||
const { t } = useExternalI18n();
|
const { t } = useExternalI18n();
|
||||||
|
const enabledCount = EXTERNAL_CAPABILITIES.filter((item) => hasExternalCapability(session, item.code)).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExternalPage title={t("nav.overview")}>
|
<ExternalPage title={t("nav.overview")}>
|
||||||
<Card className="external-session-card" variant="outlined">
|
<Box className="external-hero">
|
||||||
<CardContent>
|
<Typography className="external-hero-eyebrow">{t("overview.welcome")}</Typography>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 4 }}>
|
<Typography className="external-hero-title" component="h2">{session.appName || session.appCode}</Typography>
|
||||||
<Box><Typography color="text.secondary" variant="caption">{t("overview.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</Typography></Box>
|
<Box className="external-hero-meta">
|
||||||
<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.currentApp")}</Typography><Typography fontWeight={700}>{session.appName || session.appCode} ({session.appCode})</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>
|
<Box><Typography color="text.secondary" variant="caption">{t("overview.account")}</Typography><Typography fontWeight={700}>{session.account}</Typography></Box>
|
||||||
</Stack>
|
<Box><Typography color="text.secondary" variant="caption">{t("overview.permissions")}</Typography><Typography fontWeight={700}>{enabledCount} / {EXTERNAL_CAPABILITIES.length}</Typography></Box>
|
||||||
</CardContent>
|
</Box>
|
||||||
</Card>
|
</Box>
|
||||||
|
<Typography className="external-section-title" component="h3">{t("overview.features")}</Typography>
|
||||||
<Box className="external-capability-grid">
|
<Box className="external-capability-grid">
|
||||||
{EXTERNAL_CAPABILITIES.map((item) => {
|
{EXTERNAL_CAPABILITIES.map((item) => {
|
||||||
const enabled = hasExternalCapability(session, item.code);
|
const enabled = hasExternalCapability(session, item.code);
|
||||||
|
const Icon = CAPABILITY_ICONS[item.code] || ListAltOutlined;
|
||||||
const content = (
|
const content = (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack alignItems="flex-start" spacing={1.5}>
|
<Stack spacing={1.5} sx={{ alignItems: "flex-start" }}>
|
||||||
{enabled ? <CheckCircleOutlineOutlined color="success" /> : <LockOutlined color="disabled" />}
|
<Box className="external-capability-icon"><Icon fontSize="small" /></Box>
|
||||||
<Typography fontWeight={700}>{t(item.labelKey)}</Typography>
|
<Typography fontWeight={700}>{t(item.labelKey)}</Typography>
|
||||||
<Chip color={enabled ? "success" : "default"} label={enabled ? t("overview.enabled") : t("overview.notEnabled")} size="small" variant="outlined" />
|
<Chip color={enabled ? "success" : "default"} label={enabled ? t("overview.enabled") : t("overview.notEnabled")} size="small" variant="outlined" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export default function RoomsPage() {
|
|||||||
const canEdit = hasExternalCapability(session, "room:update");
|
const canEdit = hasExternalCapability(session, "room:update");
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
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: "owner", label: t("rooms.owner"), render: (item) => item.ownerName || "-" },
|
||||||
{ key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") },
|
{ key: "region", label: t("rooms.visibleRegion"), render: (item) => item.visibleRegionId || t("rooms.allRegions") },
|
||||||
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> },
|
{ key: "status", label: t("common.status"), render: (item) => <StatusChip status={item.status} /> },
|
||||||
|
|||||||
@ -49,12 +49,12 @@ export default function UsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
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: "prettyId", label: t("users.prettyId"), render: (user) => user.prettyId || "-" },
|
||||||
{ key: "country", label: t("users.countryGender"), render: (user) => `${user.country || "-"} / ${translatedGender(user.gender, t)}` },
|
{ 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: "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: "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]);
|
], [canBan, canEdit, canGrantLevel, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -21,11 +21,11 @@ import { translateExternalError } from "../i18n/errors.js";
|
|||||||
export function ExternalPage({ actions, children, title }) {
|
export function ExternalPage({ actions, children, title }) {
|
||||||
return (
|
return (
|
||||||
<Box className="external-page">
|
<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">
|
<Typography component="h1" variant="h5">
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</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>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
@ -55,7 +55,8 @@ export function ExternalPageState({ error, loading, onRetry }) {
|
|||||||
|
|
||||||
export function ResponsiveDataList({ columns, emptyText, items, rowKey }) {
|
export function ResponsiveDataList({ columns, emptyText, items, rowKey }) {
|
||||||
const theme = useTheme();
|
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();
|
const { t } = useExternalI18n();
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
@ -113,7 +114,7 @@ export function PagePagination({ page, pageSize, total, onChange }) {
|
|||||||
const { t } = useExternalI18n();
|
const { t } = useExternalI18n();
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
return (
|
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>
|
<Typography color="text.secondary" variant="body2">{t("pagination.total", { count: total })}</Typography>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<Button disabled={page <= 1} onClick={() => onChange(page - 1)} variant="outlined">{t("pagination.previous")}</Button>
|
<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,
|
html,
|
||||||
body,
|
body,
|
||||||
#external-admin-root {
|
#external-admin-root {
|
||||||
@ -6,41 +107,69 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-system);
|
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 {
|
.external-login-page {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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));
|
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 {
|
.external-login-card {
|
||||||
width: min(100%, 420px);
|
width: min(100%, 420px);
|
||||||
border: 1px solid var(--border);
|
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);
|
box-shadow: var(--shadow-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-login-card .MuiCardContent-root {
|
.external-login-card .MuiCardContent-root {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 28px;
|
gap: 26px;
|
||||||
padding: 32px;
|
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 {
|
.external-language-switcher {
|
||||||
min-width: 140px !important;
|
min-width: 140px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-login-avatar,
|
|
||||||
.external-logo-mark {
|
|
||||||
background: var(--brand-gradient) !important;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.external-full-state {
|
.external-full-state {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -50,34 +179,61 @@ body {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* App shell
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
.external-shell,
|
.external-shell,
|
||||||
.external-main {
|
.external-main {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-main {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.external-header {
|
.external-header {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
background: rgba(255, 255, 255, 0.96) !important;
|
background: var(--bg-header) !important;
|
||||||
backdrop-filter: blur(12px);
|
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 {
|
.external-header .MuiToolbar-root {
|
||||||
min-height: 64px;
|
min-height: 60px;
|
||||||
gap: 10px;
|
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 {
|
.external-user-avatar {
|
||||||
width: 34px !important;
|
width: 34px !important;
|
||||||
height: 34px !important;
|
height: 34px !important;
|
||||||
font-size: 13px !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 {
|
.external-sidebar {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-sidebar);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(214, 181, 110, 0.05), transparent 18%),
|
||||||
|
var(--bg-sidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-logo {
|
.external-logo {
|
||||||
@ -86,41 +242,44 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 20px;
|
padding: 14px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-logo-mark {
|
.external-logo-name {
|
||||||
width: 38px;
|
font-family: var(--font-display) !important;
|
||||||
height: 38px;
|
letter-spacing: 0.05em;
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow: var(--brand-shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-nav {
|
.external-nav {
|
||||||
padding: 12px !important;
|
padding: 12px !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-nav .MuiListItemButton-root {
|
.external-nav .MuiListItemButton-root {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
color: var(--text-secondary);
|
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 {
|
.external-nav .MuiListItemButton-root:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-nav .MuiListItemButton-root.Mui-selected,
|
.external-nav .MuiListItemButton-root.Mui-selected,
|
||||||
.external-nav .MuiListItemButton-root.Mui-selected:hover {
|
.external-nav .MuiListItemButton-root.Mui-selected:hover {
|
||||||
background: var(--primary-surface-strong);
|
background: linear-gradient(90deg, rgba(214, 181, 110, 0.16), rgba(214, 181, 110, 0.05));
|
||||||
color: var(--primary);
|
color: var(--primary-strong);
|
||||||
font-weight: 700;
|
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 {
|
.external-nav .MuiListItemIcon-root {
|
||||||
@ -128,10 +287,158 @@ body {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-main {
|
.external-sidebar-footer {
|
||||||
background: var(--bg-page);
|
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 {
|
.external-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@ -139,12 +446,17 @@ body {
|
|||||||
animation: external-page-enter var(--motion-slow) var(--ease-emphasized) both;
|
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-session-card,
|
||||||
.external-table-container,
|
.external-table-container,
|
||||||
.external-filter-bar,
|
.external-filter-bar,
|
||||||
.external-form-panel,
|
.external-form-panel,
|
||||||
.external-empty {
|
.external-empty {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
@ -158,32 +470,6 @@ body {
|
|||||||
min-width: min(100%, 220px);
|
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 {
|
.external-table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@ -192,11 +478,23 @@ body {
|
|||||||
background: var(--bg-card-strong);
|
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 {
|
.external-table-container .MuiTableCell-root {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-color: var(--row-border);
|
border-color: var(--row-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-table-container .MuiTableRow-hover:hover {
|
||||||
|
background: var(--bg-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.external-empty {
|
.external-empty {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -217,31 +515,151 @@ body {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile record cards */
|
||||||
|
|
||||||
.external-mobile-card .external-mobile-row {
|
.external-mobile-card .external-mobile-row {
|
||||||
display: grid;
|
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;
|
align-items: start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 0;
|
padding: 11px 0;
|
||||||
border-bottom: 1px solid var(--row-border);
|
border-bottom: 1px solid var(--row-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-mobile-card .external-mobile-row:last-child {
|
.external-mobile-card .external-mobile-row:last-child {
|
||||||
border-bottom: 0;
|
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 {
|
.external-dialog-fields {
|
||||||
padding-top: 8px;
|
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 {
|
.external-image-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--primary-border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
|
background: rgba(214, 181, 110, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-image-field img,
|
.external-image-field img,
|
||||||
@ -249,7 +667,7 @@ body {
|
|||||||
width: 104px;
|
width: 104px;
|
||||||
height: 68px;
|
height: 68px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,7 +675,7 @@ body {
|
|||||||
width: 88px;
|
width: 88px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: var(--bg-card-strong);
|
background: var(--bg-card-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,23 +691,66 @@ body {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Responsive
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.external-capability-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 899px) {
|
@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-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) {
|
@media (max-width: 599px) {
|
||||||
.external-login-card .MuiCardContent-root { padding: 24px 20px; }
|
.external-login-card .MuiCardContent-root { padding: 26px 20px 30px; }
|
||||||
.external-page { padding: 14px; gap: 14px; }
|
.external-page { gap: 14px; padding-inline: 14px; }
|
||||||
.external-capability-grid { grid-template-columns: 1fr; }
|
.external-capability-grid { gap: 10px; }
|
||||||
.external-capability-card { min-height: 128px; }
|
.external-capability-card { min-height: 118px; }
|
||||||
.external-filter-bar .MuiTextField-root { width: 100%; }
|
|
||||||
.external-image-field { align-items: flex-start; flex-direction: column; }
|
.external-image-field { align-items: flex-start; flex-direction: column; }
|
||||||
.external-header .external-language-switcher { min-width: 94px !important; max-width: 112px; }
|
.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) {
|
@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,
|
listAppUsers,
|
||||||
listPrettyDisplayIDs,
|
listPrettyDisplayIDs,
|
||||||
recyclePrettyDisplayID,
|
recyclePrettyDisplayID,
|
||||||
|
revokeAppUserResource,
|
||||||
unbanAppUser,
|
unbanAppUser,
|
||||||
} from "./api";
|
} 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 () => {
|
test("app user projection normalizes levels roles ban and successful login fields", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -48,6 +48,29 @@ export async function getAppUser(userId: EntityId): Promise<AppUserDto> {
|
|||||||
return normalizeAppUser(data);
|
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>> {
|
export async function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
|
||||||
const endpoint = API_ENDPOINTS.appListLoginLogs;
|
const endpoint = API_ENDPOINTS.appListLoginLogs;
|
||||||
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(
|
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(
|
||||||
|
|||||||
@ -913,7 +913,7 @@
|
|||||||
|
|
||||||
.resourceItem {
|
.resourceItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 44px minmax(0, 1fr);
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -923,6 +923,11 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resourceRevokeButton {
|
||||||
|
min-width: 72px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.resourceThumb,
|
.resourceThumb,
|
||||||
.resourceThumbFallback {
|
.resourceThumbFallback {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
|
|||||||
@ -60,12 +60,13 @@ export function AppUserDetailProvider({ children }) {
|
|||||||
open={Boolean(detailUser)}
|
open={Boolean(detailUser)}
|
||||||
user={detailUser}
|
user={detailUser}
|
||||||
onClose={closeAppUserDetail}
|
onClose={closeAppUserDetail}
|
||||||
|
onReloadUser={() => openAppUserDetail(detailUser)}
|
||||||
/>
|
/>
|
||||||
</AppUserDetailContext.Provider>
|
</AppUserDetailContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
function AppUserDetailDrawer({ loading, onClose, onReloadUser, open, user }) {
|
||||||
if (!open || !user) {
|
if (!open || !user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -83,6 +84,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
user={user}
|
user={user}
|
||||||
onOpenFullDetails={onClose}
|
onOpenFullDetails={onClose}
|
||||||
|
onReloadUser={onReloadUser}
|
||||||
/>
|
/>
|
||||||
</SideDrawer>
|
</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 { beforeEach, expect, test, vi } from "vitest";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.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 { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -81,11 +86,13 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<MemoryRouter initialEntries={["/app/users?status=active"]}>
|
<ConfirmProvider>
|
||||||
<AppUserDetailProvider>
|
<MemoryRouter initialEntries={["/app/users?status=active"]}>
|
||||||
<DetailTrigger />
|
<AppUserDetailProvider>
|
||||||
</AppUserDetailProvider>
|
<DetailTrigger />
|
||||||
</MemoryRouter>
|
</AppUserDetailProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ConfirmProvider>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
|
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();
|
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() {
|
function DetailTrigger() {
|
||||||
const { openAppUserDetail } = useAppUserDetail();
|
const { openAppUserDetail } = useAppUserDetail();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||||
|
import UndoOutlined from "@mui/icons-material/UndoOutlined";
|
||||||
import Tab from "@mui/material/Tab";
|
import Tab from "@mui/material/Tab";
|
||||||
import Tabs from "@mui/material/Tabs";
|
import Tabs from "@mui/material/Tabs";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.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 { 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 { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||||||
import {
|
import {
|
||||||
BanCountdown,
|
BanCountdown,
|
||||||
@ -33,6 +36,7 @@ export function AppUserDetailView({
|
|||||||
loading = false,
|
loading = false,
|
||||||
mode = "drawer",
|
mode = "drawer",
|
||||||
onOpenFullDetails,
|
onOpenFullDetails,
|
||||||
|
onReloadUser,
|
||||||
user,
|
user,
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -103,7 +107,12 @@ export function AppUserDetailView({
|
|||||||
{activeTab === "overview" ? <OverviewPanel user={user} /> : null}
|
{activeTab === "overview" ? <OverviewPanel user={user} /> : null}
|
||||||
{activeTab === "assets" ? <AssetsPanel balances={balances} user={user} /> : null}
|
{activeTab === "assets" ? <AssetsPanel balances={balances} user={user} /> : null}
|
||||||
{activeTab === "resources" ? (
|
{activeTab === "resources" ? (
|
||||||
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
|
<ResourcesPanel
|
||||||
|
equippedResources={equippedResources}
|
||||||
|
onReloadUser={onReloadUser}
|
||||||
|
resources={resources}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
||||||
{activeTab === "device" ? <DevicePanel 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailSection title={`当前佩戴 (${equippedResources.length})`}>
|
<DetailSection title={`当前佩戴 (${equippedResources.length})`}>
|
||||||
<ResourceList resources={equippedResources} />
|
<ResourceList
|
||||||
|
resources={equippedResources}
|
||||||
|
revokingEntitlementId={revokingEntitlementId}
|
||||||
|
onRevokeResource={revokeResource}
|
||||||
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
<DetailSection title={`资源资产 (${resources.length})`}>
|
<DetailSection title={`资源资产 (${resources.length})`}>
|
||||||
<ResourceList resources={resources} />
|
<ResourceList
|
||||||
|
resources={resources}
|
||||||
|
revokingEntitlementId={revokingEntitlementId}
|
||||||
|
onRevokeResource={revokeResource}
|
||||||
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -437,7 +497,7 @@ function AssetBalanceList({ balances }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceList({ resources }) {
|
function ResourceList({ onRevokeResource, resources, revokingEntitlementId }) {
|
||||||
if (!resources.length) {
|
if (!resources.length) {
|
||||||
return <div className={styles.emptyInline}>当前无数据</div>;
|
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||||
}
|
}
|
||||||
@ -448,7 +508,7 @@ function ResourceList({ resources }) {
|
|||||||
<ResourceThumb resource={resource} />
|
<ResourceThumb resource={resource} />
|
||||||
<div className={styles.resourceBody}>
|
<div className={styles.resourceBody}>
|
||||||
<div className={styles.resourceTitle}>
|
<div className={styles.resourceTitle}>
|
||||||
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
<span>{resourceDisplayName(resource)}</span>
|
||||||
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.resourceMeta}>
|
<div className={styles.resourceMeta}>
|
||||||
@ -456,12 +516,28 @@ function ResourceList({ resources }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||||||
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceDisplayName(resource) {
|
||||||
|
return resource?.name || resource?.resourceCode || `资源 ${resource?.resourceId || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
function ResourceThumb({ resource }) {
|
function ResourceThumb({ resource }) {
|
||||||
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export function AppUserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.detailPageContent}>
|
<div className={styles.detailPageContent}>
|
||||||
<DataState error={pageError} loading={false} onRetry={normalizedUserId ? reload : undefined}>
|
<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>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,11 +2,17 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
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 { getAppUser } from "@/features/app-users/api";
|
||||||
import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx";
|
import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx";
|
||||||
|
|
||||||
// AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。
|
// 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 () => {
|
test("full user detail route loads the URL user and returns to its source list", async () => {
|
||||||
vi.mocked(getAppUser).mockResolvedValue({
|
vi.mocked(getAppUser).mockResolvedValue({
|
||||||
@ -26,19 +32,23 @@ test("full user detail route loads the URL user and returns to its source list",
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<MemoryRouter
|
<ToastProvider>
|
||||||
initialEntries={[
|
<ConfirmProvider>
|
||||||
{
|
<MemoryRouter
|
||||||
pathname: "/app/users/10001",
|
initialEntries={[
|
||||||
state: { from: "/app/users?status=active" },
|
{
|
||||||
},
|
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>
|
||||||
</Routes>
|
<Route path="/app/users/:userId" element={<AppUserDetailPage />} />
|
||||||
</MemoryRouter>
|
<Route path="/app/users" element={<LocationValue />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</ToastProvider>
|
||||||
</QueryProvider>,
|
</QueryProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -433,6 +433,7 @@ test("resource shop APIs use generated admin paths", async () => {
|
|||||||
await upsertResourceShopItems({
|
await upsertResourceShopItems({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
coinPrice: 120,
|
||||||
durationDays: 3,
|
durationDays: 3,
|
||||||
effectiveFromMs: 0,
|
effectiveFromMs: 0,
|
||||||
effectiveToMs: 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(String(upsertUrl)).toContain("/api/v1/admin/resource-shop/items");
|
||||||
expect(upsertInit?.method).toBe("POST");
|
expect(upsertInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(upsertInit?.body))).toMatchObject({
|
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(String(enableUrl)).toContain("/api/v1/admin/resource-shop/items/99/enable");
|
||||||
expect(enableInit?.method).toBe("POST");
|
expect(enableInit?.method).toBe("POST");
|
||||||
|
|||||||
@ -328,6 +328,7 @@ export interface ResourceGroupPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceShopItemPayload {
|
export interface ResourceShopItemPayload {
|
||||||
|
coinPrice: number;
|
||||||
durationDays: number;
|
durationDays: number;
|
||||||
effectiveFromMs: number;
|
effectiveFromMs: number;
|
||||||
effectiveToMs: number;
|
effectiveToMs: number;
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import {
|
|||||||
cpRelationTypeOptions,
|
cpRelationTypeOptions,
|
||||||
defaultGiftTypeOptions,
|
defaultGiftTypeOptions,
|
||||||
resourceIdentityAutoGrantOptions,
|
resourceIdentityAutoGrantOptions,
|
||||||
|
resourceShopDurationOptions,
|
||||||
resourceShopSellableTypes,
|
resourceShopSellableTypes,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
@ -314,6 +315,68 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option
|
|||||||
throw new Error("资源选项分页过多,请收窄筛选条件");
|
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() {
|
function loadResourceGrantOptions() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetchAllOptionPages(listResources, { status: "active" }),
|
fetchAllOptionPages(listResources, { status: "active" }),
|
||||||
@ -685,9 +748,11 @@ export function useResourceShopPage() {
|
|||||||
const [purchaseKeyword, setPurchaseKeyword] = useState("");
|
const [purchaseKeyword, setPurchaseKeyword] = useState("");
|
||||||
const [drawerQuery, setDrawerQuery] = useState("");
|
const [drawerQuery, setDrawerQuery] = useState("");
|
||||||
const [drawerResourceType, setDrawerResourceType] = useState("");
|
const [drawerResourceType, setDrawerResourceType] = useState("");
|
||||||
const [batchDurationDays, setBatchDurationDays] = useState("1");
|
|
||||||
const [resourceOptions, setResourceOptions] = useState([]);
|
const [resourceOptions, setResourceOptions] = useState([]);
|
||||||
|
const [shopItemOptions, setShopItemOptions] = useState([]);
|
||||||
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
||||||
|
const [resourceOptionsError, setResourceOptionsError] = useState("");
|
||||||
|
const [resourceOptionsLoadVersion, setResourceOptionsLoadVersion] = useState(0);
|
||||||
const [draftByResourceId, setDraftByResourceId] = useState({});
|
const [draftByResourceId, setDraftByResourceId] = useState({});
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@ -732,25 +797,18 @@ export function useResourceShopPage() {
|
|||||||
queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage],
|
queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage],
|
||||||
});
|
});
|
||||||
const shopItemsByResourceId = useMemo(() => {
|
const shopItemsByResourceId = useMemo(() => {
|
||||||
const items = new Map();
|
return groupResourceShopItems(shopItemOptions);
|
||||||
(result.data?.items || []).forEach((item) => {
|
}, [shopItemOptions]);
|
||||||
if (item.resourceId) {
|
|
||||||
items.set(Number(item.resourceId), item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}, [result.data?.items]);
|
|
||||||
const selectedDrafts = useMemo(
|
const selectedDrafts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(draftByResourceId)
|
Object.values(draftByResourceId)
|
||||||
.map((draft) => ({
|
.flatMap((draftGroup) => Object.values(draftGroup.specsByDuration || {}))
|
||||||
...draft,
|
.sort(
|
||||||
resource: resourceOptions.find(
|
(left, right) =>
|
||||||
(resource) => String(resource.resourceId) === String(draft.resourceId),
|
Number(left.sortOrder || 0) - Number(right.sortOrder || 0) ||
|
||||||
),
|
Number(left.durationDays || 0) - Number(right.durationDays || 0),
|
||||||
}))
|
),
|
||||||
.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)),
|
[draftByResourceId],
|
||||||
[draftByResourceId, resourceOptions],
|
|
||||||
);
|
);
|
||||||
const filteredResourceOptions = useMemo(() => {
|
const filteredResourceOptions = useMemo(() => {
|
||||||
const keyword = drawerQuery.trim().toLowerCase();
|
const keyword = drawerQuery.trim().toLowerCase();
|
||||||
@ -771,15 +829,28 @@ export function useResourceShopPage() {
|
|||||||
}
|
}
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
setResourceOptionsLoading(true);
|
setResourceOptionsLoading(true);
|
||||||
fetchAllOptionPages(listResources, { status: "active" })
|
setResourceOptionsError("");
|
||||||
.then((data) => {
|
setResourceOptions([]);
|
||||||
|
setShopItemOptions([]);
|
||||||
|
// 抽屉同时拉全资源和现有售卖项,避免当前列表分页把同一资源的其他天数规格漏掉或误判为新增。
|
||||||
|
Promise.all([
|
||||||
|
fetchAllOptionPages(listResources, { status: "active" }),
|
||||||
|
fetchAllOptionPages(listResourceShopItems),
|
||||||
|
])
|
||||||
|
.then(([resourceData, shopItemData]) => {
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
setResourceOptions(toShopResourceOptions(data.items || []));
|
setResourceOptions(toShopResourceOptions(resourceData.items || []));
|
||||||
|
setShopItemOptions(shopItemData.items || []);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
showToast(err.message || "加载售卖资源失败", "error");
|
const message = err.message || "加载售卖资源失败";
|
||||||
|
// 任一请求失败都不能沿用上次候选;否则“旧资源 + 空规格”会让运营误以为是在新增商品。
|
||||||
|
setResourceOptions([]);
|
||||||
|
setShopItemOptions([]);
|
||||||
|
setResourceOptionsError(message);
|
||||||
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -790,19 +861,34 @@ export function useResourceShopPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
ignore = true;
|
ignore = true;
|
||||||
};
|
};
|
||||||
}, [drawerOpen, showToast]);
|
}, [drawerOpen, resourceOptionsLoadVersion, showToast]);
|
||||||
|
|
||||||
const openShopDrawer = () => {
|
const openShopDrawer = () => {
|
||||||
setDraftByResourceId({});
|
setDraftByResourceId({});
|
||||||
setDrawerQuery("");
|
setDrawerQuery("");
|
||||||
setDrawerResourceType("");
|
setDrawerResourceType("");
|
||||||
setBatchDurationDays("1");
|
setResourceOptions([]);
|
||||||
|
setShopItemOptions([]);
|
||||||
|
setResourceOptionsError("");
|
||||||
|
setResourceOptionsLoadVersion((current) => current + 1);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reloadShopDrawerOptions = () => {
|
||||||
|
if (!drawerOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftByResourceId({});
|
||||||
|
setResourceOptionsError("");
|
||||||
|
setResourceOptionsLoadVersion((current) => current + 1);
|
||||||
|
};
|
||||||
|
|
||||||
const closeShopDrawer = () => {
|
const closeShopDrawer = () => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setDraftByResourceId({});
|
setDraftByResourceId({});
|
||||||
|
setResourceOptions([]);
|
||||||
|
setShopItemOptions([]);
|
||||||
|
setResourceOptionsError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPurchaseDrawer = () => {
|
const openPurchaseDrawer = () => {
|
||||||
@ -821,41 +907,78 @@ export function useResourceShopPage() {
|
|||||||
const resourceId = String(resource.resourceId);
|
const resourceId = String(resource.resourceId);
|
||||||
setDraftByResourceId((current) => {
|
setDraftByResourceId((current) => {
|
||||||
if (current[resourceId]) {
|
if (current[resourceId]) {
|
||||||
|
if (!canUnselectResourceShopDraftGroup(current[resourceId])) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
delete next[resourceId];
|
delete next[resourceId];
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
const existing = shopItemsByResourceId.get(Number(resource.resourceId));
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[resourceId]: {
|
[resourceId]: buildResourceShopDraftGroup(
|
||||||
durationDays: String(existing?.durationDays || batchDurationDays),
|
resource,
|
||||||
effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs),
|
shopItemsByResourceId.get(Number(resource.resourceId)) || [],
|
||||||
effectiveTo: msToDatetimeLocal(existing?.effectiveToMs),
|
Object.keys(current).length,
|
||||||
resourceId,
|
),
|
||||||
sortOrder: String(existing?.sortOrder ?? Object.keys(current).length),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDraftItem = (resourceId, patch) => {
|
const toggleDraftDuration = (resource, durationDays) => {
|
||||||
setDraftByResourceId((current) => ({
|
const resourceId = String(resource?.resourceId || "");
|
||||||
...current,
|
const normalizedDuration = String(durationDays || "");
|
||||||
[resourceId]: {
|
if (!resourceId || !normalizedDuration) {
|
||||||
...current[resourceId],
|
return;
|
||||||
...patch,
|
}
|
||||||
},
|
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) => {
|
const updateDraftSpec = (resourceId, durationDays, patch) => {
|
||||||
setBatchDurationDays(durationDays);
|
setDraftByResourceId((current) => {
|
||||||
setDraftByResourceId((current) =>
|
const draftGroup = current[resourceId];
|
||||||
Object.fromEntries(
|
const draft = draftGroup?.specsByDuration?.[durationDays];
|
||||||
Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]),
|
if (!draft) {
|
||||||
),
|
return current;
|
||||||
);
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[resourceId]: {
|
||||||
|
...draftGroup,
|
||||||
|
specsByDuration: {
|
||||||
|
...draftGroup.specsByDuration,
|
||||||
|
[durationDays]: { ...draft, ...patch },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitShopItems = async (event) => {
|
const submitShopItems = async (event) => {
|
||||||
@ -866,15 +989,17 @@ export function useResourceShopPage() {
|
|||||||
setLoadingAction("resource-shop-upsert");
|
setLoadingAction("resource-shop-upsert");
|
||||||
try {
|
try {
|
||||||
const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts });
|
const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts });
|
||||||
|
// 一个资源的每个天数规格都是独立商品行,价格、时间和排序必须逐条提交,不能再按资源合并。
|
||||||
await upsertResourceShopItems({
|
await upsertResourceShopItems({
|
||||||
items: parsed.items.map((item) => ({
|
items: parsed.items.map((item) => ({
|
||||||
|
coinPrice: Number(item.coinPrice),
|
||||||
durationDays: Number(item.durationDays),
|
durationDays: Number(item.durationDays),
|
||||||
effectiveFromMs: datetimeLocalToMs(item.effectiveFrom),
|
effectiveFromMs: Number(item.effectiveFromMs || 0),
|
||||||
effectiveToMs: datetimeLocalToMs(item.effectiveTo),
|
effectiveToMs: Number(item.effectiveToMs || 0),
|
||||||
resourceId: Number(item.resourceId),
|
resourceId: Number(item.resourceId),
|
||||||
shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId,
|
shopItemId: item.shopItemId ? Number(item.shopItemId) : undefined,
|
||||||
sortOrder: Number(item.sortOrder || 0),
|
sortOrder: Number(item.sortOrder || 0),
|
||||||
status: "active",
|
status: item.status || "active",
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
showToast("售卖资源已保存", "success");
|
showToast("售卖资源已保存", "success");
|
||||||
@ -920,8 +1045,6 @@ export function useResourceShopPage() {
|
|||||||
return {
|
return {
|
||||||
...sharedPageState({ page, query, result, setPage, setQuery }),
|
...sharedPageState({ page, query, result, setPage, setQuery }),
|
||||||
abilities,
|
abilities,
|
||||||
applyBatchDuration,
|
|
||||||
batchDurationDays,
|
|
||||||
changeDrawerQuery: setDrawerQuery,
|
changeDrawerQuery: setDrawerQuery,
|
||||||
changeDrawerResourceType: setDrawerResourceType,
|
changeDrawerResourceType: setDrawerResourceType,
|
||||||
changeResourceType: resetSetter(setResourceType, setPage),
|
changeResourceType: resetSetter(setResourceType, setPage),
|
||||||
@ -931,6 +1054,7 @@ export function useResourceShopPage() {
|
|||||||
drawerOpen,
|
drawerOpen,
|
||||||
drawerQuery,
|
drawerQuery,
|
||||||
drawerResourceType,
|
drawerResourceType,
|
||||||
|
draftByResourceId,
|
||||||
filteredResourceOptions,
|
filteredResourceOptions,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
openShopDrawer,
|
openShopDrawer,
|
||||||
@ -944,7 +1068,9 @@ export function useResourceShopPage() {
|
|||||||
purchaseResourceType,
|
purchaseResourceType,
|
||||||
purchaseUserFilter,
|
purchaseUserFilter,
|
||||||
reloadPurchaseOrders: purchaseResult.reload,
|
reloadPurchaseOrders: purchaseResult.reload,
|
||||||
|
reloadShopDrawerOptions,
|
||||||
setPurchasePage,
|
setPurchasePage,
|
||||||
|
resourceOptionsError,
|
||||||
resourceOptionsLoading,
|
resourceOptionsLoading,
|
||||||
resourceType,
|
resourceType,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
@ -953,11 +1079,12 @@ export function useResourceShopPage() {
|
|||||||
status,
|
status,
|
||||||
submitShopItems,
|
submitShopItems,
|
||||||
toggleDraftResource,
|
toggleDraftResource,
|
||||||
|
toggleDraftDuration,
|
||||||
toggleShopItem,
|
toggleShopItem,
|
||||||
changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage),
|
changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage),
|
||||||
changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage),
|
changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage),
|
||||||
changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage),
|
changePurchaseUserFilter: resetSetter(setPurchaseUserFilter, setPurchasePage),
|
||||||
updateDraftItem,
|
updateDraftSpec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,15 @@ import { expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyGiftPriceDefaults,
|
applyGiftPriceDefaults,
|
||||||
applyGiftResourceSelection,
|
applyGiftResourceSelection,
|
||||||
|
buildResourceShopDraftGroup,
|
||||||
buildGiftResourceDrafts,
|
buildGiftResourceDrafts,
|
||||||
|
canUnselectResourceShopDraftGroup,
|
||||||
|
canUnselectResourceShopDraftSpec,
|
||||||
cpRelationTypeFromGift,
|
cpRelationTypeFromGift,
|
||||||
cpRelationTypeFromPresentationJson,
|
cpRelationTypeFromPresentationJson,
|
||||||
cpRelationTypeShortLabel,
|
cpRelationTypeShortLabel,
|
||||||
fetchAllOptionPages,
|
fetchAllOptionPages,
|
||||||
|
groupResourceShopItems,
|
||||||
normalizeGiftPresentationJson,
|
normalizeGiftPresentationJson,
|
||||||
} from "./useResourcePages.js";
|
} 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);
|
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", () => {
|
test("reads CP relation type from gift presentation json", () => {
|
||||||
expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother");
|
expect(cpRelationTypeFromPresentationJson('{"cp_relation_type":"brother"}')).toBe("brother");
|
||||||
expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister");
|
expect(cpRelationTypeFromPresentationJson('{"cpRelationType":"sister"}')).toBe("sister");
|
||||||
|
|||||||
@ -123,13 +123,15 @@ export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) {
|
|||||||
if (!layout) {
|
if (!layout) {
|
||||||
return normalizeMetadataJSON(metadataJson);
|
return normalizeMetadataJSON(metadataJson);
|
||||||
}
|
}
|
||||||
|
const keepCustomFrames = alphaLayout === "custom";
|
||||||
const nextLayout = createMp4AlphaLayout({
|
const nextLayout = createMp4AlphaLayout({
|
||||||
alphaLayout,
|
alphaLayout,
|
||||||
alphaFrame: layout.alpha_frame,
|
// 左右/上下方向变化时必须按新方向重算矩形;沿用旧矩形会让预览仍显示上一个通道顺序。
|
||||||
|
alphaFrame: keepCustomFrames ? layout.alpha_frame : undefined,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
confidence: layout.confidence,
|
confidence: layout.confidence,
|
||||||
detectVersion: layout.detect_version,
|
detectVersion: layout.detect_version,
|
||||||
rgbFrame: layout.rgb_frame,
|
rgbFrame: keepCustomFrames ? layout.rgb_frame : undefined,
|
||||||
videoH: layout.video_h,
|
videoH: layout.video_h,
|
||||||
videoW: layout.video_w,
|
videoW: layout.video_w,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -75,6 +75,7 @@ import {
|
|||||||
mp4AlphaLayoutOptions,
|
mp4AlphaLayoutOptions,
|
||||||
mp4LayoutLabel,
|
mp4LayoutLabel,
|
||||||
readMp4VideoInfo,
|
readMp4VideoInfo,
|
||||||
|
removeMp4AlphaLayoutMetadata,
|
||||||
updateMp4AlphaLayoutFrame,
|
updateMp4AlphaLayoutFrame,
|
||||||
updateMp4AlphaLayoutKind,
|
updateMp4AlphaLayoutKind,
|
||||||
} from "@/features/resources/mp4AlphaLayout.js";
|
} from "@/features/resources/mp4AlphaLayout.js";
|
||||||
@ -995,39 +996,42 @@ function mp4ResourceAnimationSource(resource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) {
|
function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) {
|
||||||
const submitDisabled = disabled || loading || hasUnconfirmedMp4Layout(form.metadataJson);
|
const [animationUploading, setAnimationUploading] = useState(false);
|
||||||
const { showToast } = useToast();
|
const [previewUploading, setPreviewUploading] = useState(false);
|
||||||
|
const submitDisabled =
|
||||||
|
disabled || loading || animationUploading || previewUploading || hasUnconfirmedMp4Layout(form.metadataJson);
|
||||||
const profileCardLayout =
|
const profileCardLayout =
|
||||||
form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null;
|
form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null;
|
||||||
const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson);
|
const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson);
|
||||||
const handleAnimationSelected = async (file) => {
|
const handleAnimationSelected = async (file) => {
|
||||||
|
let detectedMp4Layout = null;
|
||||||
if (isMp4File(file)) {
|
if (isMp4File(file)) {
|
||||||
try {
|
detectedMp4Layout = await detectMp4AlphaLayout(file);
|
||||||
const layout = await detectMp4AlphaLayout(file);
|
}
|
||||||
setForm((previous) => ({
|
let detectedProfileCardLayout = null;
|
||||||
...previous,
|
if (form.resourceType === "profile_card") {
|
||||||
metadataJson: mergeMp4AlphaLayoutMetadata(previous.metadataJson, layout),
|
detectedProfileCardLayout = await detectProfileCardLayout(file);
|
||||||
}));
|
}
|
||||||
} catch (err) {
|
// 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。
|
||||||
showToast(err.message || "MP4 透明布局解析失败", "error");
|
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);
|
||||||
}
|
}
|
||||||
}
|
return { ...previous, animationUrl, 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 (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog
|
||||||
@ -1055,7 +1059,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
onChange={(event) => setForm({ ...form, resourceCode: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || animationUploading}
|
||||||
label="资源类型"
|
label="资源类型"
|
||||||
required
|
required
|
||||||
select
|
select
|
||||||
@ -1229,26 +1233,31 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
<AdminFormSection title="素材">
|
<AdminFormSection title="素材">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<UploadField
|
<UploadField
|
||||||
|
key={open ? "resource-preview-open" : "resource-preview-closed"}
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="资源封面"
|
label="资源封面"
|
||||||
showSourceActions
|
showSourceActions
|
||||||
value={form.previewUrl}
|
value={form.previewUrl}
|
||||||
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
||||||
|
onUploadingChange={setPreviewUploading}
|
||||||
/>
|
/>
|
||||||
<UploadField
|
<UploadField
|
||||||
|
key={open ? "resource-animation-open" : "resource-animation-closed"}
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="动效素材"
|
label="动效素材"
|
||||||
|
mp4Layout={mp4Layout}
|
||||||
showSourceActions
|
showSourceActions
|
||||||
value={form.animationUrl}
|
value={form.animationUrl}
|
||||||
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
|
onChange={handleAnimationChange}
|
||||||
onFileSelected={handleAnimationSelected}
|
onFileSelected={handleAnimationSelected}
|
||||||
|
onUploadingChange={setAnimationUploading}
|
||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
{mp4Layout ? (
|
{mp4Layout ? (
|
||||||
<Mp4AlphaLayoutEditor
|
<Mp4AlphaLayoutEditor
|
||||||
disabled={disabled}
|
disabled={disabled || animationUploading}
|
||||||
metadataJson={form.metadataJson}
|
metadataJson={form.metadataJson}
|
||||||
onChange={(metadataJson) => setForm((previous) => ({ ...previous, metadataJson }))}
|
onChange={(metadataJson) => setForm((previous) => ({ ...previous, metadataJson }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.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 { createOptionsColumnFilter, createTextColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
@ -25,7 +26,11 @@ import {
|
|||||||
resourceStatusFilters,
|
resourceStatusFilters,
|
||||||
resourceTypeLabels,
|
resourceTypeLabels,
|
||||||
} from "@/features/resources/constants.js";
|
} 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";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const shopColumns = [
|
const shopColumns = [
|
||||||
@ -201,7 +206,12 @@ function ResourceShopDrawer({ page }) {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled || page.resourceOptionsLoading || page.selectedDrafts.length === 0}
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
page.resourceOptionsLoading ||
|
||||||
|
Boolean(page.resourceOptionsError) ||
|
||||||
|
page.selectedDrafts.length === 0
|
||||||
|
}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
@ -212,7 +222,7 @@ function ResourceShopDrawer({ page }) {
|
|||||||
as="form"
|
as="form"
|
||||||
open={page.drawerOpen}
|
open={page.drawerOpen}
|
||||||
title="添加售卖资源"
|
title="添加售卖资源"
|
||||||
width="wide"
|
width="detail"
|
||||||
onClose={saving ? undefined : page.closeShopDrawer}
|
onClose={saving ? undefined : page.closeShopDrawer}
|
||||||
onSubmit={page.submitShopItems}
|
onSubmit={page.submitShopItems}
|
||||||
>
|
>
|
||||||
@ -236,21 +246,12 @@ function ResourceShopDrawer({ page }) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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>
|
</div>
|
||||||
<DataState error={null} loading={page.resourceOptionsLoading} onRetry={page.openShopDrawer}>
|
<DataState
|
||||||
|
error={page.resourceOptionsError}
|
||||||
|
loading={page.resourceOptionsLoading}
|
||||||
|
onRetry={page.reloadShopDrawerOptions}
|
||||||
|
>
|
||||||
{page.filteredResourceOptions.length > 0 ? (
|
{page.filteredResourceOptions.length > 0 ? (
|
||||||
<div className={styles.shopSelectionList}>
|
<div className={styles.shopSelectionList}>
|
||||||
{page.filteredResourceOptions.map((resource) => (
|
{page.filteredResourceOptions.map((resource) => (
|
||||||
@ -335,61 +336,94 @@ function ResourceShopPurchaseDrawer({ page }) {
|
|||||||
function ResourceSelectionRow({ disabled, page, resource }) {
|
function ResourceSelectionRow({ disabled, page, resource }) {
|
||||||
const resourceId = String(resource.resourceId);
|
const resourceId = String(resource.resourceId);
|
||||||
const selected = page.selectedResourceIds.has(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 (
|
return (
|
||||||
<div className={styles.shopSelectionRow}>
|
<div className={styles.shopSelectionRow}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected}
|
checked={selected}
|
||||||
disabled={disabled}
|
disabled={disabled || !canUnselectGroup}
|
||||||
inputProps={{ "aria-label": selected ? "取消选择资源" : "选择资源" }}
|
inputProps={{
|
||||||
|
"aria-label":
|
||||||
|
!canUnselectGroup
|
||||||
|
? "资源已有售卖规格,请在列表启停"
|
||||||
|
: selected
|
||||||
|
? "取消选择资源"
|
||||||
|
: "选择资源",
|
||||||
|
}}
|
||||||
onChange={() => page.toggleDraftResource(resource)}
|
onChange={() => page.toggleDraftResource(resource)}
|
||||||
/>
|
/>
|
||||||
<ResourceIdentity resource={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 ? (
|
{draft ? (
|
||||||
<div className={styles.shopSelectionControls}>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="售卖天数"
|
inputProps={{ min: 1, step: 1 }}
|
||||||
select
|
label="售卖价格"
|
||||||
|
required
|
||||||
size="small"
|
size="small"
|
||||||
value={draft.durationDays}
|
type="number"
|
||||||
onChange={(event) => page.updateDraftItem(resourceId, { durationDays: event.target.value })}
|
value={draft.coinPrice}
|
||||||
>
|
onChange={(event) => updateDraft({ coinPrice: 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 })}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TimeRangeFilter
|
||||||
InputLabelProps={{ shrink: true }}
|
className={styles.shopSpecTimeRange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="结束时间"
|
label="生效时间"
|
||||||
size="small"
|
value={{ endMs: draft.effectiveToMs, startMs: draft.effectiveFromMs }}
|
||||||
type="datetime-local"
|
onChange={(range) =>
|
||||||
value={draft.effectiveTo}
|
updateDraft({ effectiveFromMs: range.startMs, effectiveToMs: range.endMs })
|
||||||
onChange={(event) => page.updateDraftItem(resourceId, { effectiveTo: event.target.value })}
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: 1 }}
|
||||||
label="排序"
|
label="排序"
|
||||||
size="small"
|
size="small"
|
||||||
type="number"
|
type="number"
|
||||||
value={draft.sortOrder}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
.shopDrawerTools {
|
.shopDrawerTools {
|
||||||
display: grid;
|
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);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,16 +282,43 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
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;
|
padding: var(--space-2) 0;
|
||||||
border-bottom: 1px solid var(--border-muted);
|
border-bottom: 1px solid var(--border-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shopSelectionControls {
|
.shopSpecList {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-2);
|
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 {
|
.shopBasePrice {
|
||||||
@ -667,7 +694,7 @@
|
|||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.shopDrawerTools,
|
.shopDrawerTools,
|
||||||
.shopSelectionRow,
|
.shopSelectionRow,
|
||||||
.shopSelectionControls,
|
.shopSpecRow,
|
||||||
.batchTableHead,
|
.batchTableHead,
|
||||||
.batchTableRow {
|
.batchTableRow {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -676,4 +703,9 @@
|
|||||||
.shopSelectionRow {
|
.shopSelectionRow {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shopSpecList,
|
||||||
|
.shopSpecDisabled {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -359,23 +359,28 @@ export const resourceShopItemsFormSchema = z
|
|||||||
items: z
|
items: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
coinPrice: z.union([z.string(), z.number()]),
|
||||||
durationDays: z.union([z.string(), z.number()]),
|
durationDays: z.union([z.string(), z.number()]),
|
||||||
effectiveFrom: z.string().optional(),
|
effectiveFromMs: z.union([z.string(), z.number()]).optional(),
|
||||||
effectiveTo: z.string().optional(),
|
effectiveToMs: z.union([z.string(), z.number()]).optional(),
|
||||||
resourceId: z.union([z.string(), z.number()]),
|
resourceId: z.union([z.string(), z.number()]),
|
||||||
|
shopItemId: z.union([z.string(), z.number()]).optional(),
|
||||||
sortOrder: z.union([z.string(), z.number()]).optional(),
|
sortOrder: z.union([z.string(), z.number()]).optional(),
|
||||||
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1, "请至少选择一个资源"),
|
.min(1, "请至少选择一个资源"),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const resourceIds = new Set();
|
const specificationKeys = new Set();
|
||||||
value.items.forEach((item, index) => {
|
value.items.forEach((item, index) => {
|
||||||
const resourceId = Number(item.resourceId);
|
const resourceId = Number(item.resourceId);
|
||||||
const durationDays = String(item.durationDays || "").trim();
|
const durationDays = String(item.durationDays || "").trim();
|
||||||
const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom);
|
const coinPrice = Number(item.coinPrice);
|
||||||
const effectiveToMs = datetimeLocalToMs(item.effectiveTo);
|
const effectiveFromMs = optionalEpochMs(item.effectiveFromMs);
|
||||||
|
const effectiveToMs = optionalEpochMs(item.effectiveToMs);
|
||||||
const sortOrder = Number(item.sortOrder || 0);
|
const sortOrder = Number(item.sortOrder || 0);
|
||||||
|
const specificationKey = `${resourceId}:${durationDays}`;
|
||||||
|
|
||||||
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
if (!Number.isInteger(resourceId) || resourceId <= 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@ -384,14 +389,14 @@ export const resourceShopItemsFormSchema = z
|
|||||||
path: ["items", index, "resourceId"],
|
path: ["items", index, "resourceId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (resourceIds.has(resourceId)) {
|
if (specificationKeys.has(specificationKey)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "同一个资源不能重复添加",
|
message: "同一个资源的相同售卖天数不能重复添加",
|
||||||
path: ["items", index, "resourceId"],
|
path: ["items", index, "durationDays"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
resourceIds.add(resourceId);
|
specificationKeys.add(specificationKey);
|
||||||
if (!resourceShopDurations.includes(durationDays)) {
|
if (!resourceShopDurations.includes(durationDays)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
@ -399,25 +404,32 @@ export const resourceShopItemsFormSchema = z
|
|||||||
path: ["items", index, "durationDays"],
|
path: ["items", index, "durationDays"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!Number.isSafeInteger(coinPrice) || coinPrice <= 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请输入大于 0 的售卖价格",
|
||||||
|
path: ["items", index, "coinPrice"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (effectiveFromMs < 0) {
|
if (effectiveFromMs < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请选择生效开始时间",
|
message: "请选择生效开始时间",
|
||||||
path: ["items", index, "effectiveFrom"],
|
path: ["items", index, "effectiveFromMs"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (effectiveToMs < 0) {
|
if (effectiveToMs < 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请选择生效结束时间",
|
message: "请选择生效结束时间",
|
||||||
path: ["items", index, "effectiveTo"],
|
path: ["items", index, "effectiveToMs"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "生效结束时间必须晚于开始时间",
|
message: "生效结束时间必须晚于开始时间",
|
||||||
path: ["items", index, "effectiveTo"],
|
path: ["items", index, "effectiveToMs"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
|
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) {
|
function datetimeLocalToMs(value) {
|
||||||
const trimmed = String(value || "").trim();
|
const trimmed = String(value || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@ -340,13 +340,22 @@ describe("resource form schema", () => {
|
|||||||
expect(() => parseForm(resourceGrantFormSchema, { ...payload, durationDays: "0" })).toThrow(FormValidationError);
|
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, {
|
const payload = parseForm(resourceShopItemsFormSchema, {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
coinPrice: "100",
|
||||||
durationDays: "3",
|
durationDays: "3",
|
||||||
effectiveFrom: "",
|
effectiveFromMs: "",
|
||||||
effectiveTo: "",
|
effectiveToMs: "",
|
||||||
|
resourceId: "11",
|
||||||
|
sortOrder: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
coinPrice: "260",
|
||||||
|
durationDays: "7",
|
||||||
|
effectiveFromMs: "",
|
||||||
|
effectiveToMs: "",
|
||||||
resourceId: "11",
|
resourceId: "11",
|
||||||
sortOrder: "0",
|
sortOrder: "0",
|
||||||
},
|
},
|
||||||
@ -354,18 +363,33 @@ describe("resource form schema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.items[0].durationDays).toBe("3");
|
expect(payload.items[0].durationDays).toBe("3");
|
||||||
|
expect(payload.items[1].coinPrice).toBe("260");
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseForm(resourceShopItemsFormSchema, {
|
parseForm(resourceShopItemsFormSchema, {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
coinPrice: "100",
|
||||||
durationDays: "2",
|
durationDays: "2",
|
||||||
effectiveFrom: "",
|
effectiveFromMs: "",
|
||||||
effectiveTo: "",
|
effectiveToMs: "",
|
||||||
resourceId: "11",
|
resourceId: "11",
|
||||||
sortOrder: "0",
|
sortOrder: "0",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toThrow(FormValidationError);
|
).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -312,6 +312,7 @@ export const API_OPERATIONS = {
|
|||||||
retryRedPacketRefund: "retryRedPacketRefund",
|
retryRedPacketRefund: "retryRedPacketRefund",
|
||||||
retryRoomRpsSettlement: "retryRoomRpsSettlement",
|
retryRoomRpsSettlement: "retryRoomRpsSettlement",
|
||||||
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
||||||
|
revokeAppUserResource: "revokeAppUserResource",
|
||||||
revokeResourceGrant: "revokeResourceGrant",
|
revokeResourceGrant: "revokeResourceGrant",
|
||||||
savePolicyTemplate: "savePolicyTemplate",
|
savePolicyTemplate: "savePolicyTemplate",
|
||||||
search: "search",
|
search: "search",
|
||||||
@ -2486,6 +2487,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-turnover-reward:retry",
|
permission: "room-turnover-reward:retry",
|
||||||
permissions: ["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: {
|
revokeResourceGrant: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.revokeResourceGrant,
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/resource-grants/resource": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -11343,6 +11359,27 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
grantResource: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -11386,7 +11423,25 @@ export interface operations {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: 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: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
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 { downloadResponse } from "@/shared/api/download";
|
||||||
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
import { uploadFile, uploadImage } from "@/shared/api/upload";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { Mp4Preview } from "@/shared/ui/Mp4Preview.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import styles from "./UploadField.module.css";
|
import styles from "./UploadField.module.css";
|
||||||
|
|
||||||
@ -30,8 +31,10 @@ export function UploadField({
|
|||||||
kind = "image",
|
kind = "image",
|
||||||
label,
|
label,
|
||||||
hideType = false,
|
hideType = false,
|
||||||
|
mp4Layout,
|
||||||
onChange,
|
onChange,
|
||||||
onFileSelected,
|
onFileSelected,
|
||||||
|
onUploadingChange,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
previewClassName,
|
previewClassName,
|
||||||
showSourceActions = false,
|
showSourceActions = false,
|
||||||
@ -40,6 +43,8 @@ export function UploadField({
|
|||||||
}) {
|
}) {
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
const uploadGenerationRef = useRef(0);
|
||||||
|
const onUploadingChangeRef = useRef(onUploadingChange);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreview, setLocalPreview] = useState(null);
|
const [localPreview, setLocalPreview] = useState(null);
|
||||||
@ -53,14 +58,30 @@ export function UploadField({
|
|||||||
const displayName = localPreview?.name || getDisplayValue(value);
|
const displayName = localPreview?.name || getDisplayValue(value);
|
||||||
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
const inputAccept = accept !== undefined ? accept : useImageUpload && isImage ? imageAccept : undefined;
|
||||||
const canPreviewVideo = Boolean(source && assetKind === "mp4");
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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 = () => {
|
const openPicker = () => {
|
||||||
if (!disabled && !uploading) {
|
if (!disabled && !uploading) {
|
||||||
@ -85,18 +106,38 @@ export function UploadField({
|
|||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const uploadGeneration = ++uploadGenerationRef.current;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
onUploadingChange?.(true);
|
||||||
try {
|
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);
|
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);
|
setLocalPreview(null);
|
||||||
showToast("上传成功", "success");
|
showToast("上传成功", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (uploadGeneration !== uploadGenerationRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLocalPreview(null);
|
setLocalPreview(null);
|
||||||
showToast(err.message || "上传失败", "error");
|
showToast(err.message || "上传失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
if (uploadGeneration === uploadGenerationRef.current) {
|
||||||
|
setUploading(false);
|
||||||
|
onUploadingChange?.(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -155,7 +196,12 @@ export function UploadField({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={openPicker}
|
onClick={openPicker}
|
||||||
>
|
>
|
||||||
<AssetPreview assetKind={assetKind} isImage={isImage} src={source} />
|
<AssetPreview
|
||||||
|
assetKind={assetKind}
|
||||||
|
isImage={isImage}
|
||||||
|
mp4Layout={previewMp4Layout}
|
||||||
|
src={source}
|
||||||
|
/>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<span className={styles.overlay}>
|
<span className={styles.overlay}>
|
||||||
<CircularProgress color="inherit" size={18} />
|
<CircularProgress color="inherit" size={18} />
|
||||||
@ -249,6 +295,7 @@ export function UploadField({
|
|||||||
</div>
|
</div>
|
||||||
<VideoPreviewDialog
|
<VideoPreviewDialog
|
||||||
label={label}
|
label={label}
|
||||||
|
mp4Layout={previewMp4Layout}
|
||||||
open={videoPreviewOpen}
|
open={videoPreviewOpen}
|
||||||
src={canPreviewVideo ? source : ""}
|
src={canPreviewVideo ? source : ""}
|
||||||
onClose={() => setVideoPreviewOpen(false)}
|
onClose={() => setVideoPreviewOpen(false)}
|
||||||
@ -265,15 +312,13 @@ export function UploadField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoPreviewDialog({ label, onClose, open, src }) {
|
function VideoPreviewDialog({ label, mp4Layout, onClose, open, src }) {
|
||||||
return (
|
return (
|
||||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
|
<Dialog fullWidth maxWidth="sm" open={open} onClose={onClose}>
|
||||||
<DialogTitle>{label} MP4</DialogTitle>
|
<DialogTitle>{label} MP4</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className={styles.videoPreviewFrame}>
|
<div className={styles.videoPreviewFrame}>
|
||||||
{src ? (
|
{src ? <Mp4Preview autoPlay controls expanded layout={mp4Layout} src={src} /> : null}
|
||||||
<video className={styles.videoPreview} controls playsInline preload="metadata" src={src} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@ -283,7 +328,7 @@ function VideoPreviewDialog({ label, onClose, open, src }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetPreview({ assetKind, isImage, src }) {
|
function AssetPreview({ assetKind, isImage, mp4Layout, src }) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
<span className={styles.empty}>
|
<span className={styles.empty}>
|
||||||
@ -297,6 +342,9 @@ function AssetPreview({ assetKind, isImage, src }) {
|
|||||||
if (assetKind === "pag") {
|
if (assetKind === "pag") {
|
||||||
return <PAGAssetPreview src={src} />;
|
return <PAGAssetPreview src={src} />;
|
||||||
}
|
}
|
||||||
|
if (assetKind === "mp4") {
|
||||||
|
return <Mp4Preview autoPlay layout={mp4Layout} src={src} />;
|
||||||
|
}
|
||||||
if (assetKind === "image" || isImage) {
|
if (assetKind === "image" || isImage) {
|
||||||
return <RasterAssetPreview src={src} />;
|
return <RasterAssetPreview src={src} />;
|
||||||
}
|
}
|
||||||
@ -390,7 +438,8 @@ function SVGAAssetPreview({ src }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.player}>
|
<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}
|
{failed ? <span className={styles.empty}>SVGA 预览失败</span> : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -160,14 +160,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerSurface canvas,
|
.playerSurface canvas {
|
||||||
.pagCanvas {
|
display: block;
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagCanvas {
|
.pagCanvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user