Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ef5b0c08d | ||
|
|
c206c72612 | ||
|
|
9065382192 | ||
|
|
ef260df749 | ||
|
|
cf98d5bd9a | ||
|
|
c7ca4f23ec | ||
|
|
50da0a569b | ||
|
|
5e9ed4c0c5 |
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import {
|
|||||||
fetchSocialBiKpi,
|
fetchSocialBiKpi,
|
||||||
fetchSocialBiMaster,
|
fetchSocialBiMaster,
|
||||||
fetchSocialBiOverview,
|
fetchSocialBiOverview,
|
||||||
|
fetchSocialBiRequirements,
|
||||||
fetchStatisticsOverview
|
fetchStatisticsOverview
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ vi.mock("./api.js", () => ({
|
|||||||
fetchSocialBiKpi: vi.fn(),
|
fetchSocialBiKpi: vi.fn(),
|
||||||
fetchSocialBiMaster: vi.fn(),
|
fetchSocialBiMaster: vi.fn(),
|
||||||
fetchSocialBiOverview: vi.fn(),
|
fetchSocialBiOverview: vi.fn(),
|
||||||
|
fetchSocialBiRequirements: vi.fn(),
|
||||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||||
fetchStatisticsOverview: vi.fn(),
|
fetchStatisticsOverview: vi.fn(),
|
||||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
@ -58,12 +60,14 @@ beforeEach(() => {
|
|||||||
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||||
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||||
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
|
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
|
||||||
|
fetchSocialBiRequirements.mockResolvedValue({ columns: [], daily_rows: [], section: "new_users", total: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.history.pushState(null, "", "/");
|
window.history.pushState(null, "", "/");
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps current data visible during scheduled refresh", async () => {
|
test("keeps current data visible during scheduled refresh", async () => {
|
||||||
@ -330,7 +334,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
|||||||
|
|
||||||
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
|
||||||
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
|
|
||||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
||||||
@ -360,6 +364,119 @@ 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("renders social BI data requirements table with section filters and CSV export", async () => {
|
||||||
|
window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11");
|
||||||
|
fetchSocialBiMaster.mockResolvedValue({
|
||||||
|
access: { all: true, scopes: [] },
|
||||||
|
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
|
||||||
|
operators: [],
|
||||||
|
permissions: {},
|
||||||
|
regions: []
|
||||||
|
});
|
||||||
|
fetchSocialBiOverview.mockResolvedValue({
|
||||||
|
access: { all: true, scopes: [] },
|
||||||
|
apps: [{ app_code: "lalu", app_name: "Lalu", daily_series: [], kind: "hyapp", total: {} }]
|
||||||
|
});
|
||||||
|
fetchSocialBiRequirements.mockImplementation(async ({ section }) => {
|
||||||
|
if (section === "revenue") {
|
||||||
|
return {
|
||||||
|
access: { all: true, scopes: [] },
|
||||||
|
apps: [{
|
||||||
|
app_code: "lalu",
|
||||||
|
app_name: "Lalu",
|
||||||
|
kind: "hyapp",
|
||||||
|
sections: [{
|
||||||
|
columns: [
|
||||||
|
{ key: "recharge_users", label: "充值用户", type: "count" },
|
||||||
|
{ key: "recharge_usd_minor", label: "充值金额", type: "money_minor" }
|
||||||
|
],
|
||||||
|
daily_series: [{ metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "2026-06-05" }],
|
||||||
|
key: section,
|
||||||
|
total: { label: "汇总", metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "total" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access: { all: true, scopes: [] },
|
||||||
|
apps: [{
|
||||||
|
app_code: "lalu",
|
||||||
|
app_name: "Lalu",
|
||||||
|
kind: "hyapp",
|
||||||
|
sections: [{
|
||||||
|
columns: [
|
||||||
|
{ key: "app_download_users", label: "下载APP人数", type: "count" },
|
||||||
|
{ key: "registration_success_rate", label: "注册成功率", type: "ratio" }
|
||||||
|
],
|
||||||
|
daily_series: [{ metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "2026-06-05" }],
|
||||||
|
key: section,
|
||||||
|
total: { label: "汇总", metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "total" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DatabiApp />);
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
||||||
|
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("radio", { name: "数据需求" }).click();
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
payerType: "all",
|
||||||
|
regionIds: [9, 11],
|
||||||
|
section: "new_users",
|
||||||
|
userRole: "all"
|
||||||
|
}));
|
||||||
|
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByText("下载APP人数")).toBeTruthy();
|
||||||
|
expect(screen.getByText("注册成功率")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("66.66%").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByRole("radiogroup", { name: "用户身份" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("tab", { name: "营收" }).click();
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("radio", { name: "主播" }).click();
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("radio", { name: "已付费" }).click();
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
payerType: "paid",
|
||||||
|
section: "revenue",
|
||||||
|
userRole: "host"
|
||||||
|
}));
|
||||||
|
expect(screen.getByText("充值用户")).toBeTruthy();
|
||||||
|
expect(screen.getByText("充值金额")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const urlMock = { createObjectURL: vi.fn(() => "blob:social-bi-requirements"), revokeObjectURL: vi.fn() };
|
||||||
|
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||||
|
vi.stubGlobal("URL", urlMock);
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("button", { name: "导出 CSV" }).click();
|
||||||
|
});
|
||||||
|
expect(urlMock.createObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
expect(anchorClick).toHaveBeenCalledTimes(1);
|
||||||
|
anchorClick.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
||||||
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
||||||
fetchSocialBiMaster.mockResolvedValue({
|
fetchSocialBiMaster.mockResolvedValue({
|
||||||
|
|||||||
@ -180,6 +180,40 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs
|
|||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
|
||||||
|
const query = {};
|
||||||
|
if (statTz) {
|
||||||
|
query.stat_tz = statTz;
|
||||||
|
}
|
||||||
|
if (startMs) {
|
||||||
|
query.start_ms = String(startMs);
|
||||||
|
}
|
||||||
|
if (endMs) {
|
||||||
|
query.end_ms = String(endMs);
|
||||||
|
}
|
||||||
|
if (appCodes?.length) {
|
||||||
|
query.app_codes = appCodes.join(",");
|
||||||
|
}
|
||||||
|
if (regionId) {
|
||||||
|
query.region_id = String(regionId);
|
||||||
|
} else if (regionIds?.length) {
|
||||||
|
query.region_ids = regionIds.join(",");
|
||||||
|
}
|
||||||
|
if (section) {
|
||||||
|
query.section = section;
|
||||||
|
}
|
||||||
|
if (userRole) {
|
||||||
|
query.user_role = userRole;
|
||||||
|
}
|
||||||
|
if (payerType) {
|
||||||
|
query.payer_type = payerType;
|
||||||
|
}
|
||||||
|
if (newUserType) {
|
||||||
|
query.new_user_type = newUserType;
|
||||||
|
}
|
||||||
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
|
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
|
||||||
const query = {};
|
const query = {};
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { fetchFilterOptions, fetchSocialBiFilterOptions, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
|
import {
|
||||||
|
fetchFilterOptions,
|
||||||
|
fetchSocialBiFilterOptions,
|
||||||
|
fetchSocialBiRequirements,
|
||||||
|
fetchStatisticsOverview,
|
||||||
|
getCurrentAppCode,
|
||||||
|
getDefaultAppOptions,
|
||||||
|
setCurrentAppCode
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@ -87,6 +95,31 @@ test("loads social BI apps and operation users from admin APIs", async () => {
|
|||||||
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
|
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loads social BI requirements with section role and payer filters", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
|
||||||
|
|
||||||
|
const data = await fetchSocialBiRequirements({
|
||||||
|
appCodes: ["lalu", "huwaa"],
|
||||||
|
endMs: 20,
|
||||||
|
payerType: "paid",
|
||||||
|
regionIds: [9, 11],
|
||||||
|
section: "revenue",
|
||||||
|
startMs: 10,
|
||||||
|
statTz: "Asia/Shanghai",
|
||||||
|
userRole: "host"
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestURL = new URL(String(fetch.mock.calls[0][0]));
|
||||||
|
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/requirements");
|
||||||
|
expect(requestURL.searchParams.get("app_codes")).toBe("lalu,huwaa");
|
||||||
|
expect(requestURL.searchParams.get("section")).toBe("revenue");
|
||||||
|
expect(requestURL.searchParams.get("user_role")).toBe("host");
|
||||||
|
expect(requestURL.searchParams.get("payer_type")).toBe("paid");
|
||||||
|
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
|
||||||
|
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
|
||||||
|
expect(data.total.recharge_users).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
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 })));
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function SocialBiApp() {
|
|||||||
const initial = useMemo(() => readStateFromURL(), []);
|
const initial = useMemo(() => readStateFromURL(), []);
|
||||||
const [filters, setFilters] = useState(initial.filters);
|
const [filters, setFilters] = useState(initial.filters);
|
||||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
||||||
const data = useSocialBiData(filters);
|
const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeStateToURL(filters, viewKey);
|
writeStateToURL(filters, viewKey);
|
||||||
|
|||||||
@ -249,6 +249,24 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// averageMetricMaps:跨天"平均值"行。可加字段取日均(总量/覆盖天数),余额快照保持末日快照,
|
||||||
|
// 比例/均值类分子分母同除天数后不变,直接沿用汇总行的派生值。
|
||||||
|
export function averageMetricMaps(rows, dayCount) {
|
||||||
|
if (!rows.length || !dayCount || dayCount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out = aggregateMetricMaps(rows, { acrossTime: true });
|
||||||
|
ADDITIVE_METRIC_KEYS.forEach((key) => {
|
||||||
|
if (SNAPSHOT_METRIC_KEYS.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (out[key] !== null && out[key] !== undefined) {
|
||||||
|
out[key] = out[key] / dayCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// App 品牌色:chips、图表系列、行标识统一取色,保证同一 App 全站同色。
|
// App 品牌色:chips、图表系列、行标识统一取色,保证同一 App 全站同色。
|
||||||
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
||||||
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
|
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
|
||||||
|
|||||||
@ -2,24 +2,28 @@
|
|||||||
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js";
|
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
|
||||||
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
||||||
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
||||||
|
|
||||||
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
||||||
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
||||||
|
|
||||||
export function useSocialBiData(filters) {
|
export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
||||||
const [master, setMaster] = useState(null);
|
const [master, setMaster] = useState(null);
|
||||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
||||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
const [funnel, setFunnel] = useState(null);
|
const [funnel, setFunnel] = useState(null);
|
||||||
const [kpi, setKpi] = useState(null);
|
const [kpi, setKpi] = useState(null);
|
||||||
|
const [requirements, setRequirements] = useState(null);
|
||||||
|
const [requirementsError, setRequirementsError] = useState("");
|
||||||
|
const [isRequirementsLoading, setIsRequirementsLoading] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState([]);
|
const [errors, setErrors] = useState([]);
|
||||||
const [refreshToken, setRefreshToken] = useState(0);
|
const [refreshToken, setRefreshToken] = useState(0);
|
||||||
const requestSeq = useRef(0);
|
const requestSeq = useRef(0);
|
||||||
|
const requirementsSeq = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
@ -74,9 +78,13 @@ export function useSocialBiData(filters) {
|
|||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
|
||||||
|
const funnelRequest = includeFunnel
|
||||||
|
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
|
: Promise.resolve(null);
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
||||||
fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
funnelRequest,
|
||||||
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
||||||
if (sequence !== requestSeq.current) {
|
if (sequence !== requestSeq.current) {
|
||||||
@ -94,7 +102,9 @@ export function useSocialBiData(filters) {
|
|||||||
setOverview(null);
|
setOverview(null);
|
||||||
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
||||||
}
|
}
|
||||||
if (funnelResult.status === "fulfilled") {
|
if (!includeFunnel) {
|
||||||
|
setFunnel(null);
|
||||||
|
} else if (funnelResult.status === "fulfilled") {
|
||||||
setFunnel(funnelResult.value);
|
setFunnel(funnelResult.value);
|
||||||
(funnelResult.value?.apps || []).forEach((app) => {
|
(funnelResult.value?.apps || []).forEach((app) => {
|
||||||
if (app.error) {
|
if (app.error) {
|
||||||
@ -117,7 +127,60 @@ export function useSocialBiData(filters) {
|
|||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
|
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
|
||||||
|
|
||||||
|
const loadRequirements = useCallback(
|
||||||
|
({ newUserType, payerType, section, userRole } = {}) => {
|
||||||
|
if (!isMasterSettled) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const sequence = ++requirementsSeq.current;
|
||||||
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
|
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||||||
|
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
||||||
|
setIsRequirementsLoading(true);
|
||||||
|
setRequirementsError("");
|
||||||
|
setRequirements(null);
|
||||||
|
if (!requestScopes.length) {
|
||||||
|
const emptyResult = { access: overview?.access || null, apps: [] };
|
||||||
|
setRequirements(emptyResult);
|
||||||
|
setIsRequirementsLoading(false);
|
||||||
|
return Promise.resolve(emptyResult);
|
||||||
|
}
|
||||||
|
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
|
||||||
|
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
||||||
|
...scope,
|
||||||
|
endMs,
|
||||||
|
newUserType,
|
||||||
|
payerType,
|
||||||
|
section,
|
||||||
|
startMs,
|
||||||
|
statTz: SOCIAL_BI_TZ,
|
||||||
|
userRole
|
||||||
|
})))
|
||||||
|
.then((results) => {
|
||||||
|
if (sequence !== requirementsSeq.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = mergeRequirementResponses(results);
|
||||||
|
setRequirements(data);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (sequence === requirementsSeq.current) {
|
||||||
|
setRequirements(null);
|
||||||
|
setRequirementsError(error?.message || "数据需求加载失败");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (sequence === requirementsSeq.current) {
|
||||||
|
setIsRequirementsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
|
||||||
|
);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setRefreshToken((current) => current + 1);
|
setRefreshToken((current) => current + 1);
|
||||||
@ -132,10 +195,15 @@ export function useSocialBiData(filters) {
|
|||||||
funnel,
|
funnel,
|
||||||
funnelAppCodes,
|
funnelAppCodes,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isRequirementsLoading,
|
||||||
kpi,
|
kpi,
|
||||||
|
loadRequirements,
|
||||||
master,
|
master,
|
||||||
overview,
|
overview,
|
||||||
range,
|
range,
|
||||||
|
refreshToken,
|
||||||
|
requirements,
|
||||||
|
requirementsError,
|
||||||
refresh
|
refresh
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -236,3 +304,61 @@ function buildRegionByCountryIndex(master) {
|
|||||||
});
|
});
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRequirementRequestScopes(appCodes, regions) {
|
||||||
|
const selectedApps = new Set((appCodes || []).filter(Boolean));
|
||||||
|
if (!regions?.length || regions.includes(ALL)) {
|
||||||
|
return [{ appCodes: [...selectedApps] }];
|
||||||
|
}
|
||||||
|
const byApp = new Map();
|
||||||
|
regions.forEach((selection) => {
|
||||||
|
const value = String(selection || "").trim();
|
||||||
|
if (!value || value === ALL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.startsWith("app:")) {
|
||||||
|
const appCode = value.slice(4).trim().toLowerCase();
|
||||||
|
if (appCode) {
|
||||||
|
byApp.set(appCode, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [rawAppCode, rawRegionID] = value.split(":");
|
||||||
|
const appCode = String(rawAppCode || "").trim().toLowerCase();
|
||||||
|
const regionID = Number(rawRegionID);
|
||||||
|
if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!byApp.has(appCode)) {
|
||||||
|
byApp.set(appCode, new Set());
|
||||||
|
}
|
||||||
|
byApp.get(appCode).add(regionID);
|
||||||
|
});
|
||||||
|
const scopes = [];
|
||||||
|
byApp.forEach((regionIDs, appCode) => {
|
||||||
|
if (selectedApps.size && !selectedApps.has(appCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (regionIDs === null) {
|
||||||
|
scopes.push({ appCodes: [appCode] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = [...regionIDs].sort((left, right) => left - right);
|
||||||
|
if (ids.length === 1) {
|
||||||
|
scopes.push({ appCodes: [appCode], regionId: ids[0] });
|
||||||
|
} else if (ids.length > 1) {
|
||||||
|
scopes.push({ appCodes: [appCode], regionIds: ids });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRequirementResponses(results) {
|
||||||
|
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
|
||||||
|
results.forEach((item) => {
|
||||||
|
if (Array.isArray(item?.apps)) {
|
||||||
|
merged.apps.push(...item.apps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
// DataTableView:数据明细视图 —— Excel 式全字段宽表(维度切换 / 列组开关 / 指标排序 /
|
// DataTableView:数据明细视图 —— Excel 式全字段宽表(维度切换 / 列组开关 / 指标排序 /
|
||||||
// 首列冻结 / CSV 导出)。行数据保持后端原始 snake_case 口径,渲染时经 metrics.js 统一格式化。
|
// 首列冻结 / CSV 导出)。行数据保持后端原始 snake_case 口径,渲染时经 metrics.js 统一格式化。
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||||
import {
|
import {
|
||||||
METRIC_GROUPS,
|
METRIC_GROUPS,
|
||||||
aggregateMetricMaps,
|
aggregateMetricMaps,
|
||||||
|
averageMetricMaps,
|
||||||
formatMetric,
|
formatMetric,
|
||||||
metricChartValue,
|
metricChartValue,
|
||||||
metricLabel,
|
metricLabel,
|
||||||
metricTooltip
|
metricTooltip
|
||||||
} from "../metrics.js";
|
} from "../metrics.js";
|
||||||
import { downloadCsv, isBlank } from "../format.js";
|
import { downloadCsv, formatCount, formatDurationMs, formatMoneyMinor, isBlank } from "../format.js";
|
||||||
import { rangeLabel } from "../state.js";
|
import { rangeLabel } from "../state.js";
|
||||||
import "./data-table-view.css";
|
import "./data-table-view.css";
|
||||||
|
|
||||||
@ -39,6 +40,126 @@ const DIMENSIONS = [
|
|||||||
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TABLE_MODES = [
|
||||||
|
{ key: "wide", label: "运营宽表" },
|
||||||
|
{ key: "requirements", label: "数据需求" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUIREMENT_SECTIONS = [
|
||||||
|
{
|
||||||
|
csvName: "p0-new-users",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
|
||||||
|
{ key: "new_users", label: "新增注册", type: "count" },
|
||||||
|
{ key: "profile_complete_users", label: "完善资料", type: "count" },
|
||||||
|
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
||||||
|
],
|
||||||
|
key: "new_users",
|
||||||
|
label: "新用户"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
csvName: "p1-revenue",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "recharge_usd_minor", label: "充值", type: "money_minor" },
|
||||||
|
{ key: "recharge_users", label: "充值用户", type: "count" },
|
||||||
|
{ key: "first_recharge_users", label: "首充用户", type: "count" },
|
||||||
|
{ key: "repeat_recharge_users", label: "复购用户", type: "count" },
|
||||||
|
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
||||||
|
],
|
||||||
|
key: "revenue",
|
||||||
|
label: "营收",
|
||||||
|
newUserFilter: true,
|
||||||
|
payerFilter: true,
|
||||||
|
roleFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
csvName: "p2-retention",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
|
||||||
|
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
|
||||||
|
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
|
||||||
|
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
|
||||||
|
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
|
||||||
|
],
|
||||||
|
key: "retention",
|
||||||
|
label: "留存",
|
||||||
|
newUserFilter: true,
|
||||||
|
payerFilter: true,
|
||||||
|
roleFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
csvName: "p2-active",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "active_users", label: "活跃用户", type: "count" },
|
||||||
|
{ key: "room_join_users", label: "进房用户", type: "count" },
|
||||||
|
{ key: "mic_users", label: "上麦用户", type: "count" },
|
||||||
|
{ key: "message_users", label: "发言用户", type: "count" },
|
||||||
|
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
||||||
|
],
|
||||||
|
key: "active",
|
||||||
|
label: "活跃",
|
||||||
|
newUserFilter: true,
|
||||||
|
payerFilter: true,
|
||||||
|
roleFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
csvName: "p4-gift",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "gift_panel_open_users", label: "礼物面板用户", type: "count" },
|
||||||
|
{ key: "gift_senders", label: "送礼用户", type: "count" },
|
||||||
|
{ key: "gift_coin_spent", label: "礼物流水", type: "coin" },
|
||||||
|
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
||||||
|
],
|
||||||
|
key: "gift",
|
||||||
|
label: "送礼",
|
||||||
|
newUserFilter: true,
|
||||||
|
roleFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
csvName: "p5-game",
|
||||||
|
fallbackColumns: [
|
||||||
|
{ key: "probability_game_panel_open_users", label: "概率游戏面板用户", type: "count" },
|
||||||
|
{ key: "game_players", label: "押注用户", type: "count" },
|
||||||
|
{ key: "game_turnover", label: "游戏流水", type: "coin" },
|
||||||
|
{ key: "game_profit", label: "游戏利润", type: "coin" },
|
||||||
|
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
|
||||||
|
],
|
||||||
|
key: "game",
|
||||||
|
label: "游戏",
|
||||||
|
newUserFilter: true,
|
||||||
|
roleFilter: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_ROLE_OPTIONS = [
|
||||||
|
{ label: "全部角色", value: "all" },
|
||||||
|
{ label: "主播", value: "host" },
|
||||||
|
{ label: "用户", value: "user" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAYER_TYPE_OPTIONS = [
|
||||||
|
{ label: "全部付费", value: "all" },
|
||||||
|
{ label: "已付费", value: "paid" },
|
||||||
|
{ label: "未付费", value: "unpaid" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 新用户 = 当天注册成功的用户;非新用户 = 当天未注册(历史注册)的用户。
|
||||||
|
const NEW_USER_OPTIONS = [
|
||||||
|
{ label: "全部用户", value: "all" },
|
||||||
|
{ label: "新用户", value: "new" },
|
||||||
|
{ label: "非新用户", value: "old" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// P5 按游戏明细的兜底列(正常由后端 game_columns 下发)。
|
||||||
|
const FALLBACK_GAME_COLUMNS = [
|
||||||
|
{ key: "game_bet_users", label: "押注用户数", type: "count" },
|
||||||
|
{ key: "game_d1_retention_rate", label: "游戏次留", type: "ratio" },
|
||||||
|
{ key: "game_arppu_coin", label: "ARPPU(金币)", type: "coin" },
|
||||||
|
{ key: "game_bet_coin", label: "押注金币", type: "coin" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const GAME_SOURCE_LABELS = { self: "自研", vendor: "厂商" };
|
||||||
|
|
||||||
function readClosedGroups() {
|
function readClosedGroups() {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
||||||
@ -89,16 +210,300 @@ function rowIdentity(row, dimKey, index) {
|
|||||||
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
|
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requirementSectionConfig(key) {
|
||||||
|
return REQUIREMENT_SECTIONS.find((section) => section.key === key) || REQUIREMENT_SECTIONS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionMatches(item, sectionKey) {
|
||||||
|
return String(item?.section ?? item?.key ?? item?.code ?? "") === sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequirementColumn(column) {
|
||||||
|
const key = String(column?.key ?? column?.field ?? column?.name ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: column.label || column.title || column.name || key,
|
||||||
|
tooltip: column.tooltip || column.description || column.remark || "",
|
||||||
|
type: column.type || column.value_type || column.format || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementAppContext(app) {
|
||||||
|
return {
|
||||||
|
app_code: app?.app_code || app?.appCode || "",
|
||||||
|
app_name: app?.app_name || app?.appName || app?.app_code || app?.appCode || "",
|
||||||
|
kind: app?.kind || "",
|
||||||
|
restricted: Boolean(app?.restricted)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRequirementRow(row, context = {}) {
|
||||||
|
if (!row || typeof row !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
|
||||||
|
const flattened = { ...context, ...row, ...metrics };
|
||||||
|
delete flattened.metrics;
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRequirementColumns(sectionItem, fallbackColumns) {
|
||||||
|
const columns = asArray(sectionItem?.columns || sectionItem?.fields || sectionItem?.metrics).map(normalizeRequirementColumn).filter(Boolean);
|
||||||
|
return columns.length ? columns : fallbackColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日行统一按日期倒序展示(最新在最上),不依赖后端返回顺序;无日期的行(如"汇总"占位)保持原相对位置沉底。
|
||||||
|
function sortRequirementDailyRows(rows) {
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const left = String(a?.stat_day || "");
|
||||||
|
const right = String(b?.stat_day || "");
|
||||||
|
if (left === right) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return left < right ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequirementPayload(payload, sectionKey) {
|
||||||
|
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
|
||||||
|
const appSections = asArray(payload?.apps).flatMap((app) => {
|
||||||
|
const context = requirementAppContext(app);
|
||||||
|
return asArray(app?.sections)
|
||||||
|
.filter((section) => sectionMatches(section, sectionKey))
|
||||||
|
.map((section) => ({ context, section }));
|
||||||
|
});
|
||||||
|
if (appSections.length) {
|
||||||
|
const firstSection = appSections.find(({ section }) => asArray(section?.columns || section?.fields || section?.metrics).length)?.section
|
||||||
|
|| appSections[0].section;
|
||||||
|
const columns = normalizedRequirementColumns(firstSection, fallbackColumns);
|
||||||
|
if (appSections.length === 1) {
|
||||||
|
const { context, section } = appSections[0];
|
||||||
|
return {
|
||||||
|
average: flattenRequirementRow(section.average, context),
|
||||||
|
columns,
|
||||||
|
rows: sortRequirementDailyRows(
|
||||||
|
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||||
|
.map((row) => flattenRequirementRow(row, context))
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
|
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rows = appSections.flatMap(({ context, section }) => {
|
||||||
|
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
|
||||||
|
const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
|
||||||
|
const dailyRows = sortRequirementDailyRows(
|
||||||
|
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||||
|
.map((row) => flattenRequirementRow(row, context))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
return [total, average, ...dailyRows].filter(Boolean);
|
||||||
|
});
|
||||||
|
return { average: null, columns, rows, total: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
|
||||||
|
|| asArray(payload?.items).find((item) => sectionMatches(item, sectionKey) && (item.columns || item.daily_rows || item.dailyRows || item.total || item.totals))
|
||||||
|
|| payload
|
||||||
|
|| {};
|
||||||
|
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
|
||||||
|
const rows = sortRequirementDailyRows(
|
||||||
|
asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
|
||||||
|
.map((row) => flattenRequirementRow(row))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
|
||||||
|
return {
|
||||||
|
average: flattenRequirementRow(sectionItem.average),
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// P5 按游戏明细:每个游戏输出 [汇总行 + 日行(倒序)],行带 app/game 上下文;列优先取后端 game_columns。
|
||||||
|
// 该明细不受用户身份/付费身份/是否新用户筛选影响(后端口径限制)。
|
||||||
|
function normalizeRequirementGamePayload(payload) {
|
||||||
|
const appSections = asArray(payload?.apps).flatMap((app) => {
|
||||||
|
const context = requirementAppContext(app);
|
||||||
|
return asArray(app?.sections)
|
||||||
|
.filter((section) => sectionMatches(section, "game"))
|
||||||
|
.map((section) => ({ context, section }));
|
||||||
|
});
|
||||||
|
const sections = appSections.length
|
||||||
|
? appSections
|
||||||
|
: asArray(payload?.sections)
|
||||||
|
.filter((section) => sectionMatches(section, "game"))
|
||||||
|
.map((section) => ({ context: {}, section }));
|
||||||
|
const columns =
|
||||||
|
sections
|
||||||
|
.map(({ section }) => asArray(section.game_columns).map(normalizeRequirementColumn).filter(Boolean))
|
||||||
|
.find((cols) => cols.length) || FALLBACK_GAME_COLUMNS;
|
||||||
|
const rows = sections.flatMap(({ context, section }) =>
|
||||||
|
asArray(section.games).flatMap((game) => {
|
||||||
|
const gameContext = {
|
||||||
|
...context,
|
||||||
|
game_key: game.game_key || "",
|
||||||
|
game_name: game.game_name || game.game_key || "--",
|
||||||
|
game_source: game.game_source || ""
|
||||||
|
};
|
||||||
|
const total = flattenRequirementRow(game.total, { ...gameContext, stat_day: "汇总" });
|
||||||
|
const daily = sortRequirementDailyRows(
|
||||||
|
asArray(game.daily_series)
|
||||||
|
.map((row) => flattenRequirementRow(row, gameContext))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
return total ? [total, ...daily] : daily;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { columns, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementGameLabel(row) {
|
||||||
|
const source = GAME_SOURCE_LABELS[String(row?.game_source || "")] || "";
|
||||||
|
const name = row?.game_name || row?.game_key || "--";
|
||||||
|
return source ? `${source}·${name}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRequirementType(column) {
|
||||||
|
const type = String(column.type || "").toLowerCase();
|
||||||
|
const key = String(column.key || "").toLowerCase();
|
||||||
|
if (type.includes("money") || key.endsWith("_usd_minor")) {
|
||||||
|
return "money_minor";
|
||||||
|
}
|
||||||
|
if (type.includes("ratio") || type.includes("rate") || type.includes("percent") || key.endsWith("_rate")) {
|
||||||
|
return "ratio";
|
||||||
|
}
|
||||||
|
if (type.includes("duration") || key.endsWith("_ms")) {
|
||||||
|
return "duration_ms";
|
||||||
|
}
|
||||||
|
if (type.includes("coin") || key.endsWith("_coin") || key.endsWith("_turnover") || key.endsWith("_profit")) {
|
||||||
|
return "coin";
|
||||||
|
}
|
||||||
|
return type || "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档展示规则:百分比固定保留 2 位小数(截断补零);整数展示整数;非整数展示 2 位小数(超出截断)。
|
||||||
|
function truncateTwoDecimals(value, { pad = false } = {}) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
const truncated = Math.trunc(numeric * 100) / 100;
|
||||||
|
return truncated.toLocaleString("en-US", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: pad ? 2 : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRequirementRatio(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRequirementValue(column, value) {
|
||||||
|
if (isBlank(value)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
const type = inferRequirementType(column);
|
||||||
|
if (type === "money_minor") {
|
||||||
|
const dollars = Number(value) / 100;
|
||||||
|
if (!Number.isFinite(dollars)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return Number.isInteger(dollars) ? formatMoneyMinor(value) : `$${truncateTwoDecimals(dollars, { pad: true })}`;
|
||||||
|
}
|
||||||
|
if (type === "ratio") {
|
||||||
|
return formatRequirementRatio(value);
|
||||||
|
}
|
||||||
|
if (type === "duration_ms") {
|
||||||
|
return formatDurationMs(value);
|
||||||
|
}
|
||||||
|
if (type === "coin" || type === "count" || type === "integer") {
|
||||||
|
const numeric = Number(value);
|
||||||
|
// 平均值行的计数/金币可能出现小数,按"非整数展示2位小数(截断)"处理。
|
||||||
|
if (Number.isFinite(numeric) && !Number.isInteger(numeric)) {
|
||||||
|
return truncateTwoDecimals(numeric, { pad: true });
|
||||||
|
}
|
||||||
|
return formatCount(value);
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric, { pad: true });
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvRequirementValue(column, value) {
|
||||||
|
if (isBlank(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const type = inferRequirementType(column);
|
||||||
|
if (type === "money_minor") {
|
||||||
|
return Number(value) / 100;
|
||||||
|
}
|
||||||
|
if (type === "ratio") {
|
||||||
|
return Number(value) * 100;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementRowIdentity(row, sectionKey, index) {
|
||||||
|
const parts = [row.app_code, row.stat_day, row.region_id].filter((part) => part !== undefined && part !== null && part !== "");
|
||||||
|
return parts.length ? `${sectionKey}:${parts.join("|")}` : `${sectionKey}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementAppName(row) {
|
||||||
|
return row?.app_name || row?.app_code || "全部";
|
||||||
|
}
|
||||||
|
|
||||||
export function DataTableView() {
|
export function DataTableView() {
|
||||||
const { derived, isLoading, range } = useSocialBi();
|
const {
|
||||||
|
derived,
|
||||||
|
isLoading,
|
||||||
|
isRequirementsLoading,
|
||||||
|
loadRequirements,
|
||||||
|
range,
|
||||||
|
refreshToken,
|
||||||
|
requirements,
|
||||||
|
requirementsError
|
||||||
|
} = useSocialBi();
|
||||||
|
const [mode, setMode] = useState("wide");
|
||||||
const [dimKey, setDimKey] = useState("appDaily");
|
const [dimKey, setDimKey] = useState("appDaily");
|
||||||
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
||||||
const [sort, setSort] = useState(null);
|
const [sort, setSort] = useState(null);
|
||||||
|
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
||||||
|
const [requirementRole, setRequirementRole] = useState("all");
|
||||||
|
const [requirementPayer, setRequirementPayer] = useState("all");
|
||||||
|
const [requirementNewUser, setRequirementNewUser] = useState("all");
|
||||||
|
|
||||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
||||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
||||||
const hasDay = dim.hasDay;
|
const hasDay = dim.hasDay;
|
||||||
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
|
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
|
||||||
|
const activeRequirementSection = requirementSectionConfig(requirementSection);
|
||||||
|
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
|
||||||
|
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
|
||||||
|
const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
|
||||||
|
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
|
||||||
|
const requirementColumns = requirementData.columns;
|
||||||
|
const requirementRows = requirementData.rows;
|
||||||
|
const requirementTotal = requirementData.total;
|
||||||
|
const requirementAverage = requirementData.average;
|
||||||
|
const requirementGameData = useMemo(
|
||||||
|
() => (requirementSection === "game" ? normalizeRequirementGamePayload(requirements) : { columns: [], rows: [] }),
|
||||||
|
[requirements, requirementSection]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
|
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
|
||||||
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
|
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
|
||||||
@ -124,16 +529,45 @@ export function DataTableView() {
|
|||||||
|
|
||||||
// 日维度的汇总跨天:余额快照类指标(金币数量/存量工资)只取最后一天,避免按天数放大。
|
// 日维度的汇总跨天:余额快照类指标(金币数量/存量工资)只取最后一天,避免按天数放大。
|
||||||
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
|
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
|
||||||
|
// 平均值行只在带日期的维度有意义:可加指标按覆盖天数取日均。
|
||||||
|
const wideDayCount = hasDay ? new Set(sortedRows.map((row) => String(row.stat_day || "")).filter(Boolean)).size : 0;
|
||||||
|
const averageRow = totalRow && hasDay && wideDayCount > 0 ? averageMetricMaps(sortedRows, wideDayCount) : null;
|
||||||
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
|
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
|
||||||
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
||||||
const hasRestricted = baseRows.some((row) => row.restricted);
|
const hasRestricted = baseRows.some((row) => row.restricted);
|
||||||
const columnCount = dimColumns.length + visibleMetrics.length;
|
const columnCount = dimColumns.length + visibleMetrics.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "requirements") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadRequirements({
|
||||||
|
newUserType: effectiveRequirementNewUser,
|
||||||
|
payerType: effectiveRequirementPayer,
|
||||||
|
section: requirementSection,
|
||||||
|
userRole: effectiveRequirementRole
|
||||||
|
});
|
||||||
|
}, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
|
||||||
|
|
||||||
const handleDimChange = (key) => {
|
const handleDimChange = (key) => {
|
||||||
setDimKey(key);
|
setDimKey(key);
|
||||||
setSort(null);
|
setSort(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequirementSectionChange = (key) => {
|
||||||
|
const nextSection = requirementSectionConfig(key);
|
||||||
|
setRequirementSection(nextSection.key);
|
||||||
|
if (!nextSection.roleFilter) {
|
||||||
|
setRequirementRole("all");
|
||||||
|
}
|
||||||
|
if (!nextSection.payerFilter) {
|
||||||
|
setRequirementPayer("all");
|
||||||
|
}
|
||||||
|
if (!nextSection.newUserFilter) {
|
||||||
|
setRequirementNewUser("all");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleGroup = (key) => {
|
const toggleGroup = (key) => {
|
||||||
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
|
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
|
||||||
setClosedGroups(next);
|
setClosedGroups(next);
|
||||||
@ -157,9 +591,34 @@ export function DataTableView() {
|
|||||||
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||||
};
|
};
|
||||||
|
|
||||||
let content;
|
const handleRequirementsExport = () => {
|
||||||
|
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
|
||||||
|
const rows = [
|
||||||
|
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
|
||||||
|
...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
|
||||||
|
...requirementRows
|
||||||
|
].map((row) => [
|
||||||
|
row.stat_day || "--",
|
||||||
|
requirementAppName(row),
|
||||||
|
...requirementColumns.map((column) => csvRequirementValue(column, row[column.key]))
|
||||||
|
]);
|
||||||
|
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameBreakdownExport = () => {
|
||||||
|
const header = ["游戏", "日期", "App", ...requirementGameData.columns.map((column) => column.label)];
|
||||||
|
const rows = requirementGameData.rows.map((row) => [
|
||||||
|
requirementGameLabel(row),
|
||||||
|
row.stat_day || "--",
|
||||||
|
requirementAppName(row),
|
||||||
|
...requirementGameData.columns.map((column) => csvRequirementValue(column, row[column.key]))
|
||||||
|
]);
|
||||||
|
downloadCsv(`social-bi-game-breakdown-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
let wideContent;
|
||||||
if (isLoading && !baseRows.length) {
|
if (isLoading && !baseRows.length) {
|
||||||
content = (
|
wideContent = (
|
||||||
<div className="sbi-dt-skeleton" aria-label="数据加载中">
|
<div className="sbi-dt-skeleton" aria-label="数据加载中">
|
||||||
{SKELETON_WIDTHS.map((width) => (
|
{SKELETON_WIDTHS.map((width) => (
|
||||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||||
@ -167,25 +626,19 @@ export function DataTableView() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (!baseRows.length) {
|
} else if (!baseRows.length) {
|
||||||
content = (
|
wideContent = (
|
||||||
<div className="sbi-empty">
|
<div className="sbi-empty">
|
||||||
<strong>当前维度暂无数据</strong>
|
<strong>当前无数据</strong>
|
||||||
<span>
|
|
||||||
{dim.dims.includes("region")
|
|
||||||
? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
|
|
||||||
: "试试更换日期区间、调整 App 筛选或切换其它维度"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (!visibleMetrics.length) {
|
} else if (!visibleMetrics.length) {
|
||||||
content = (
|
wideContent = (
|
||||||
<div className="sbi-empty">
|
<div className="sbi-empty">
|
||||||
<strong>所有列组已隐藏</strong>
|
<strong>所有列组已隐藏</strong>
|
||||||
<span>点击上方列组开关,恢复需要查看的指标列</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = (
|
wideContent = (
|
||||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||||
<table className="sbi-table sbi-dt-table">
|
<table className="sbi-table sbi-dt-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -233,6 +686,18 @@ export function DataTableView() {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
|
{averageRow ? (
|
||||||
|
<tr className="is-total">
|
||||||
|
{dimColumns.map((column, index) => (
|
||||||
|
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
||||||
|
{index === 0 ? "平均值" : column.totalText}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{visibleMetrics.map((key) => (
|
||||||
|
<td key={key}>{formatMetric(key, averageRow[key])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
{renderRows.map((row, rowIndex) => (
|
{renderRows.map((row, rowIndex) => (
|
||||||
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
||||||
{dimColumns.map((column, index) => (
|
{dimColumns.map((column, index) => (
|
||||||
@ -258,15 +723,152 @@ export function DataTableView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requirementsContent;
|
||||||
|
if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
|
||||||
|
requirementsContent = (
|
||||||
|
<div className="sbi-dt-skeleton" aria-label="数据需求加载中">
|
||||||
|
{SKELETON_WIDTHS.map((width) => (
|
||||||
|
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (requirementsError) {
|
||||||
|
requirementsContent = (
|
||||||
|
<div className="sbi-empty" role="alert">
|
||||||
|
<strong>{requirementsError}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (!requirementRows.length && !requirementTotal) {
|
||||||
|
requirementsContent = (
|
||||||
|
<div className="sbi-empty">
|
||||||
|
<strong>当前无数据</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
requirementsContent = (
|
||||||
|
<>
|
||||||
|
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||||
|
<table className="sbi-table sbi-dt-req-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="is-left sbi-dt-sticky">日期</th>
|
||||||
|
<th className="is-left">App</th>
|
||||||
|
{requirementColumns.map((column) => (
|
||||||
|
<th key={column.key}>
|
||||||
|
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requirementTotal ? (
|
||||||
|
<tr className="is-total">
|
||||||
|
<td className="is-left sbi-dt-sticky">汇总</td>
|
||||||
|
<td className="is-left">全部</td>
|
||||||
|
{requirementColumns.map((column) => (
|
||||||
|
<td key={column.key}>{formatRequirementValue(column, requirementTotal[column.key])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{requirementAverage ? (
|
||||||
|
<tr className="is-total">
|
||||||
|
<td className="is-left sbi-dt-sticky">平均值</td>
|
||||||
|
<td className="is-left">全部</td>
|
||||||
|
{requirementColumns.map((column) => (
|
||||||
|
<td key={column.key}>{formatRequirementValue(column, requirementAverage[column.key])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{requirementRows.map((row, rowIndex) => (
|
||||||
|
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
|
||||||
|
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
|
||||||
|
<td className="is-left">{requirementAppName(row)}</td>
|
||||||
|
{requirementColumns.map((column) => (
|
||||||
|
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{requirementGameData.rows.length ? (
|
||||||
|
<>
|
||||||
|
<div className="sbi-card-header">
|
||||||
|
<strong>各游戏明细</strong>
|
||||||
|
<small>押注用户/同游戏次留/ARPPU · 不受身份与新用户筛选影响</small>
|
||||||
|
</div>
|
||||||
|
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||||
|
<table className="sbi-table sbi-dt-req-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="is-left sbi-dt-sticky">游戏</th>
|
||||||
|
<th className="is-left">日期</th>
|
||||||
|
<th className="is-left">App</th>
|
||||||
|
{requirementGameData.columns.map((column) => (
|
||||||
|
<th key={column.key}>
|
||||||
|
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requirementGameData.rows.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
className={row.stat_day === "汇总" ? "is-total" : undefined}
|
||||||
|
key={`${row.app_code || ""}|${row.game_key || rowIndex}|${row.stat_day || rowIndex}`}
|
||||||
|
>
|
||||||
|
<td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
|
||||||
|
<td className="is-left">{row.stat_day || "--"}</td>
|
||||||
|
<td className="is-left">{requirementAppName(row)}</td>
|
||||||
|
{requirementGameData.columns.map((column) => (
|
||||||
|
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCount = mode === "requirements" ? requirementRows.length : sortedRows.length;
|
||||||
|
const content = mode === "requirements" ? requirementsContent : wideContent;
|
||||||
|
const exportDisabled = mode === "requirements"
|
||||||
|
? (!requirementRows.length && !requirementTotal) || isRequirementsLoading
|
||||||
|
: !sortedRows.length || !visibleMetrics.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="sbi-card">
|
<section className="sbi-card">
|
||||||
<div className="sbi-card-header">
|
<div className="sbi-card-header">
|
||||||
<strong>数据明细</strong>
|
<strong>数据明细</strong>
|
||||||
<small>
|
<small>
|
||||||
{rangeLabel(range)} · 共 {sortedRows.length} 行
|
{rangeLabel(range)} · 共 {rowCount} 行
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="sbi-dt-tools">
|
<div className="sbi-dt-tools">
|
||||||
|
<div className="sbi-seg" role="radiogroup" aria-label="数据明细模式">
|
||||||
|
{TABLE_MODES.map((item) => (
|
||||||
|
<button
|
||||||
|
aria-checked={mode === item.key}
|
||||||
|
className={mode === item.key ? "is-active" : ""}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setMode(item.key)}
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mode === "wide" ? (
|
||||||
|
<>
|
||||||
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
|
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
|
||||||
{DIMENSIONS.map((item) => (
|
{DIMENSIONS.map((item) => (
|
||||||
<button
|
<button
|
||||||
@ -300,10 +902,66 @@ export function DataTableView() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
|
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="sbi-dt-section-tabs" role="tablist" aria-label="数据需求分组">
|
||||||
|
{REQUIREMENT_SECTIONS.map((section) => (
|
||||||
|
<button
|
||||||
|
aria-selected={activeRequirementSection.key === section.key}
|
||||||
|
className={activeRequirementSection.key === section.key ? "is-active" : ""}
|
||||||
|
key={section.key}
|
||||||
|
onClick={() => handleRequirementSectionChange(section.key)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeRequirementSection.roleFilter ? (
|
||||||
|
<RequirementFilterGroup
|
||||||
|
ariaLabel="用户身份"
|
||||||
|
label="用户身份"
|
||||||
|
options={USER_ROLE_OPTIONS}
|
||||||
|
value={effectiveRequirementRole}
|
||||||
|
onChange={setRequirementRole}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeRequirementSection.payerFilter ? (
|
||||||
|
<RequirementFilterGroup
|
||||||
|
ariaLabel="付费身份"
|
||||||
|
label="付费身份"
|
||||||
|
options={PAYER_TYPE_OPTIONS}
|
||||||
|
value={effectiveRequirementPayer}
|
||||||
|
onChange={setRequirementPayer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeRequirementSection.newUserFilter ? (
|
||||||
|
<RequirementFilterGroup
|
||||||
|
ariaLabel="是否新用户"
|
||||||
|
label="是否新用户"
|
||||||
|
options={NEW_USER_OPTIONS}
|
||||||
|
value={effectiveRequirementNewUser}
|
||||||
|
onChange={setRequirementNewUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="sbi-dt-export">
|
<div className="sbi-dt-export">
|
||||||
|
{mode === "requirements" && requirementGameData.rows.length ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={!sortedRows.length || !visibleMetrics.length}
|
onClick={handleGameBreakdownExport}
|
||||||
onClick={handleExport}
|
size="small"
|
||||||
|
startIcon={<FileDownloadOutlined />}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
导出游戏明细
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
disabled={exportDisabled}
|
||||||
|
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<FileDownloadOutlined />}
|
startIcon={<FileDownloadOutlined />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -316,3 +974,25 @@ export function DataTableView() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<div className="sbi-dt-filter-group">
|
||||||
|
<span className="sbi-dt-groups-label">{label}</span>
|
||||||
|
<div className="sbi-seg" role="radiogroup" aria-label={ariaLabel}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
aria-checked={value === option.value}
|
||||||
|
className={value === option.value ? "is-active" : ""}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,37 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sbi-dt-section-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbi-dt-section-tabs > button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--sbi-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--sbi-text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbi-dt-section-tabs > button.is-active {
|
||||||
|
border-color: var(--sbi-accent);
|
||||||
|
color: var(--sbi-accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbi-dt-filter-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.sbi-dt-groups-label {
|
.sbi-dt-groups-label {
|
||||||
color: var(--sbi-text-3);
|
color: var(--sbi-text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -57,24 +88,32 @@
|
|||||||
color: var(--sbi-accent);
|
color: var(--sbi-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sbi-dt-req-table th:first-child,
|
||||||
|
.sbi-dt-req-table td:first-child {
|
||||||
|
min-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.sbi-dt-sort {
|
.sbi-dt-sort {
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
|
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
|
||||||
.sbi-dt-table .sbi-dt-sticky {
|
.sbi-dt-table .sbi-dt-sticky,
|
||||||
|
.sbi-dt-req-table .sbi-dt-sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
|
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbi-dt-table td.sbi-dt-sticky {
|
.sbi-dt-table td.sbi-dt-sticky,
|
||||||
|
.sbi-dt-req-table td.sbi-dt-sticky {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: var(--sbi-card);
|
background: var(--sbi-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sbi-dt-table th.sbi-dt-sticky {
|
.sbi-dt-table th.sbi-dt-sticky,
|
||||||
|
.sbi-dt-req-table th.sbi-dt-sticky {
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,15 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:13100/api/;
|
proxy_pass http://127.0.0.1:13100/api/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ops-api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://127.0.0.1:13114/ops-api/;
|
||||||
|
}
|
||||||
|
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
proxy_pass http://127.0.0.1:13100/healthz;
|
proxy_pass http://127.0.0.1:13100/healthz;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
validateApplicationPayload,
|
validateApplicationPayload,
|
||||||
} from "./format.js";
|
} from "./format.js";
|
||||||
import { downloadCsv } from "@/shared/api/download";
|
import { downloadCsv } from "@/shared/api/download";
|
||||||
|
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
@ -61,10 +62,13 @@ function chinaDayStart(timestamp) {
|
|||||||
function timePresets() {
|
function timePresets() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const todayStart = chinaDayStart(now);
|
const todayStart = chinaDayStart(now);
|
||||||
|
const yesterdayStart = todayStart - DAY_MS;
|
||||||
const chinaNow = new Date(now + CHINA_OFFSET_MS);
|
const chinaNow = new Date(now + CHINA_OFFSET_MS);
|
||||||
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
|
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
|
||||||
return [
|
return [
|
||||||
{ key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
|
{ key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
|
||||||
|
// 财务后台统计按中国自然日切分,昨天固定为昨日 00:00 到今日 00:00,避免滚动 24 小时影响对账口径。
|
||||||
|
{ key: "yesterday", label: "昨天", range: { endMs: todayStart, startMs: yesterdayStart } },
|
||||||
{ key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
|
{ key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
|
||||||
{ key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } },
|
{ key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } },
|
||||||
{ key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
|
{ key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
|
||||||
@ -145,7 +149,7 @@ export function FinanceApp() {
|
|||||||
paidState: "",
|
paidState: "",
|
||||||
rechargeType: "",
|
rechargeType: "",
|
||||||
regionId: "",
|
regionId: "",
|
||||||
timeRange: timePresets().find((preset) => preset.key === "30d").range,
|
timeRange: timePresets().find((preset) => preset.key === "yesterday").range,
|
||||||
}));
|
}));
|
||||||
const [rechargeExporting, setRechargeExporting] = useState(false);
|
const [rechargeExporting, setRechargeExporting] = useState(false);
|
||||||
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
||||||
@ -735,7 +739,13 @@ export function FinanceApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionState.error) {
|
if (sessionState.error) {
|
||||||
return <StateScreen actionHref="/login" actionText="重新登录" title={sessionState.error} />;
|
return (
|
||||||
|
<StateScreen
|
||||||
|
actionHref={buildLoginRedirectPath(window.location)}
|
||||||
|
actionText="重新登录"
|
||||||
|
title={sessionState.error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.canAccessFinance) {
|
if (!session?.canAccessFinance) {
|
||||||
|
|||||||
@ -1,19 +1,48 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { FinanceApp } from "./FinanceApp.jsx";
|
import { FinanceApp } from "./FinanceApp.jsx";
|
||||||
|
import {
|
||||||
|
fetchFinanceApplicationOptions,
|
||||||
|
fetchFinanceRechargeApps,
|
||||||
|
fetchFinanceRechargeOverview,
|
||||||
|
fetchFinanceRechargeSummaries,
|
||||||
|
fetchFinanceSession,
|
||||||
|
listFinanceApplications,
|
||||||
|
listFinanceCoinSellerRechargeOrders,
|
||||||
|
listFinanceRechargeDetails,
|
||||||
|
listFinanceRechargeRegions,
|
||||||
|
listFinanceWithdrawalApplications,
|
||||||
|
listMyFinanceApplications
|
||||||
|
} from "./api.js";
|
||||||
|
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
approveFinanceApplication: vi.fn(),
|
approveFinanceApplication: vi.fn(),
|
||||||
approveFinanceWithdrawalApplication: vi.fn(),
|
approveFinanceWithdrawalApplication: vi.fn(),
|
||||||
createFinanceApplication: vi.fn(),
|
createFinanceApplication: vi.fn(),
|
||||||
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
fetchFinanceApplicationOptions: vi.fn(async () => ({
|
exportFinanceRechargeBills: vi.fn(),
|
||||||
apps: [{ appCode: "lalu", appName: "lalu" }],
|
fetchFinanceApplicationOptions: vi.fn(),
|
||||||
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
|
fetchFinanceRechargeApps: vi.fn(),
|
||||||
walletIdentities: []
|
fetchFinanceRechargeOverview: vi.fn(),
|
||||||
})),
|
fetchFinanceRechargeSummaries: vi.fn(),
|
||||||
fetchFinanceSession: vi.fn(async () => ({
|
fetchFinanceSession: vi.fn(),
|
||||||
|
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
||||||
|
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
|
listFinanceApplications: vi.fn(),
|
||||||
|
listFinanceCoinSellerRechargeOrders: vi.fn(),
|
||||||
|
listFinanceRechargeDetails: vi.fn(),
|
||||||
|
listFinanceRechargeRegions: vi.fn(),
|
||||||
|
listFinanceWithdrawalApplications: vi.fn(),
|
||||||
|
listMyFinanceApplications: vi.fn(),
|
||||||
|
rejectFinanceApplication: vi.fn(),
|
||||||
|
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||||
|
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||||
|
verifyFinanceCoinSellerRechargeOrder: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultSession = {
|
||||||
canAccessFinance: true,
|
canAccessFinance: true,
|
||||||
canAuditApplication: false,
|
canAuditApplication: false,
|
||||||
canCreateApplication: true,
|
canCreateApplication: true,
|
||||||
@ -25,21 +54,71 @@ vi.mock("./api.js", () => ({
|
|||||||
canViewWithdrawalApplications: false,
|
canViewWithdrawalApplications: false,
|
||||||
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
|
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
|
||||||
user: { account: "admin", name: "admin" }
|
user: { account: "admin", name: "admin" }
|
||||||
})),
|
};
|
||||||
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
|
||||||
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
beforeEach(() => {
|
||||||
listFinanceApplications: vi.fn(),
|
vi.mocked(fetchFinanceApplicationOptions).mockResolvedValue({
|
||||||
listFinanceCoinSellerRechargeOrders: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
|
apps: [{ appCode: "lalu", appName: "lalu" }],
|
||||||
listFinanceRechargeDetails: vi.fn(),
|
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
|
||||||
listFinanceWithdrawalApplications: vi.fn(),
|
walletIdentities: []
|
||||||
listMyFinanceApplications: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
|
});
|
||||||
rejectFinanceApplication: vi.fn(),
|
vi.mocked(fetchFinanceRechargeApps).mockResolvedValue([{ appCode: "lalu", appName: "Lalu" }]);
|
||||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
|
||||||
verifyFinanceCoinSellerRechargeOrder: vi.fn()
|
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
|
||||||
}));
|
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
|
||||||
|
vi.mocked(listFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
|
||||||
|
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
vi.mocked(listMyFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
window.history.pushState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the finance entry when the expired session asks the user to log in again", async () => {
|
||||||
|
vi.mocked(fetchFinanceSession).mockRejectedValueOnce(new Error("登录已失效"));
|
||||||
|
window.history.pushState(null, "", "/finance/?view=rechargeDetails");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("link", { name: "重新登录" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults recharge finance views to yesterday in China timezone", async () => {
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 6, 8, 4));
|
||||||
|
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||||
|
...defaultSession,
|
||||||
|
canCreateApplication: false,
|
||||||
|
canViewAppRechargeDetails: true,
|
||||||
|
permissions: ["finance-recharge-detail:view"]
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const yesterdayButton = await screen.findByRole("button", { name: "昨天" });
|
||||||
|
expect(yesterdayButton).toHaveClass("is-on");
|
||||||
|
expect(screen.getByRole("button", { name: "今天" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => expect(listFinanceRechargeDetails).toHaveBeenCalled());
|
||||||
|
const [query] = vi.mocked(listFinanceRechargeDetails).mock.calls.at(-1);
|
||||||
|
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
|
||||||
|
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps create application dialog open when backdrop is clicked", async () => {
|
test("keeps create application dialog open when backdrop is clicked", async () => {
|
||||||
|
|||||||
@ -113,14 +113,18 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 100000,
|
coinAmount: 100000,
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
targetUserId: "10001",
|
targetUserId: "10001",
|
||||||
usdAmount: 10
|
usdAmount: 10
|
||||||
});
|
});
|
||||||
await verifyFinanceCoinSellerRechargeReceipt({
|
await verifyFinanceCoinSellerRechargeReceipt({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
usdAmount: 10
|
usdAmount: 10
|
||||||
});
|
});
|
||||||
await verifyFinanceCoinSellerRechargeOrder("order-1");
|
await verifyFinanceCoinSellerRechargeOrder("order-1");
|
||||||
@ -132,13 +136,24 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
|
|||||||
expect(calls[0][1]?.method).toBe("GET");
|
expect(calls[0][1]?.method).toBe("GET");
|
||||||
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges");
|
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges");
|
||||||
expect(calls[1][1]?.method).toBe("POST");
|
expect(calls[1][1]?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ externalOrderNo: "MIFA-001", providerCode: "mifapay" });
|
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
|
||||||
|
appCode: "lalu",
|
||||||
|
coinAmount: 100000,
|
||||||
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
|
targetUserId: "10001",
|
||||||
|
usdAmount: 10
|
||||||
|
});
|
||||||
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications");
|
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications");
|
||||||
expect(calls[2][1]?.method).toBe("POST");
|
expect(calls[2][1]?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({
|
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
usdAmount: 10
|
usdAmount: 10
|
||||||
});
|
});
|
||||||
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");
|
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
|||||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
@ -14,6 +15,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
COIN_SELLER_RECHARGE_CHAINS,
|
COIN_SELLER_RECHARGE_CHAINS,
|
||||||
|
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
|
||||||
COIN_SELLER_RECHARGE_PROVIDERS,
|
COIN_SELLER_RECHARGE_PROVIDERS,
|
||||||
COIN_SELLER_RECHARGE_STATUS,
|
COIN_SELLER_RECHARGE_STATUS,
|
||||||
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
||||||
@ -78,7 +80,9 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
chain: "",
|
chain: "",
|
||||||
|
providerAmount: "",
|
||||||
providerCode: value,
|
providerCode: value,
|
||||||
|
providerCurrencyCode: "",
|
||||||
}));
|
}));
|
||||||
setFormError("");
|
setFormError("");
|
||||||
setReceiptVerification(null);
|
setReceiptVerification(null);
|
||||||
@ -201,7 +205,12 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canCreate} startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={openCreate}>
|
<Button
|
||||||
|
disabled={!canCreate}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
variant="contained"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
创建币商充值
|
创建币商充值
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -234,7 +243,10 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
<td className="finance-order-cell">{item.id || "-"}</td>
|
<td className="finance-order-cell">{item.id || "-"}</td>
|
||||||
<td>{appName(item.appCode, apps)}</td>
|
<td>{appName(item.appCode, apps)}</td>
|
||||||
<td>
|
<td>
|
||||||
<Stacked primary={item.targetDisplayUserId || item.targetUserId} secondary={item.targetUserId} />
|
<Stacked
|
||||||
|
primary={item.targetDisplayUserId || item.targetUserId}
|
||||||
|
secondary={item.targetUserId}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="r num">
|
<td className="r num">
|
||||||
<Stacked
|
<Stacked
|
||||||
@ -243,10 +255,16 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Stacked primary={coinSellerRechargeProviderLabel(item.providerCode)} secondary={providerMeta(item)} />
|
<Stacked
|
||||||
|
primary={coinSellerRechargeProviderLabel(item.providerCode)}
|
||||||
|
secondary={providerMeta(item)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Stacked primary={item.externalOrderNo || "-"} secondary={providerAmountText(item)} />
|
<Stacked
|
||||||
|
primary={item.externalOrderNo || "-"}
|
||||||
|
secondary={providerAmountText(item)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="finance-remark-cell">{item.remark || "-"}</td>
|
<td className="finance-remark-cell">{item.remark || "-"}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -266,7 +284,8 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
primary={item.operatorName || "-"}
|
primary={item.operatorName || "-"}
|
||||||
secondary={[
|
secondary={[
|
||||||
item.verifiedByName && `校验:${item.verifiedByName}`,
|
item.verifiedByName && `校验:${item.verifiedByName}`,
|
||||||
item.grantedByName && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
|
item.grantedByName &&
|
||||||
|
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" / ")}
|
.join(" / ")}
|
||||||
@ -277,7 +296,8 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
primary={formatTime(item.createdAtMs)}
|
primary={formatTime(item.createdAtMs)}
|
||||||
secondary={[
|
secondary={[
|
||||||
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
|
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
|
||||||
item.grantedAtMs && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
|
item.grantedAtMs &&
|
||||||
|
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" / ")}
|
.join(" / ")}
|
||||||
@ -330,13 +350,21 @@ export function FinanceCoinSellerRechargeOrderList({
|
|||||||
) : null}
|
) : null}
|
||||||
{total > pageSize ? (
|
{total > pageSize ? (
|
||||||
<div className="finance-pagination">
|
<div className="finance-pagination">
|
||||||
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
<Button
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||||
|
>
|
||||||
上一页
|
上一页
|
||||||
</Button>
|
</Button>
|
||||||
<span>
|
<span>
|
||||||
第 {page} 页 / 共 {total} 条
|
第 {page} 页 / 共 {total} 条
|
||||||
</span>
|
</span>
|
||||||
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
<Button
|
||||||
|
disabled={!hasNextPage || loading}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||||
|
>
|
||||||
下一页
|
下一页
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -379,14 +407,37 @@ function CreateDialog({
|
|||||||
const canCreate = receiptVerification?.verified === true && !disabled;
|
const canCreate = receiptVerification?.verified === true && !disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog fullWidth maxWidth={false} open={open} slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }} onClose={disabled ? undefined : onClose}>
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth={false}
|
||||||
|
open={open}
|
||||||
|
slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }}
|
||||||
|
onClose={disabled ? undefined : onClose}
|
||||||
|
>
|
||||||
<DialogTitle>创建币商充值</DialogTitle>
|
<DialogTitle>创建币商充值</DialogTitle>
|
||||||
<DialogContent className="finance-create-dialog">
|
<DialogContent className="finance-create-dialog">
|
||||||
<form className="finance-form finance-form--dialog" id="coin-seller-recharge-order-form" onSubmit={onSubmit}>
|
<form
|
||||||
|
className="finance-form finance-form--dialog"
|
||||||
|
id="coin-seller-recharge-order-form"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
{formError ? <Alert severity="error">{formError}</Alert> : null}
|
{formError ? <Alert severity="error">{formError}</Alert> : null}
|
||||||
<div className={form.providerCode === "usdt" ? "finance-form-grid finance-form-grid--coin-seller-create" : "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"}>
|
<div
|
||||||
|
className={
|
||||||
|
form.providerCode === "usdt"
|
||||||
|
? "finance-form-grid finance-form-grid--coin-seller-create"
|
||||||
|
: "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"
|
||||||
|
}
|
||||||
|
>
|
||||||
{apps.length ? (
|
{apps.length ? (
|
||||||
<TextField disabled={disabled} label="APP" required select value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)}>
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="APP"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.appCode}
|
||||||
|
onChange={(event) => onChange("appCode", event.target.value)}
|
||||||
|
>
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
<MenuItem key={app.appCode} value={app.appCode}>
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
{app.appName || app.appCode}
|
{app.appName || app.appCode}
|
||||||
@ -394,9 +445,21 @@ function CreateDialog({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
) : (
|
) : (
|
||||||
<TextField disabled={disabled} label="APP" required value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)} />
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="APP"
|
||||||
|
required
|
||||||
|
value={form.appCode}
|
||||||
|
onChange={(event) => onChange("appCode", event.target.value)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField disabled={disabled} label="目标用户 ID" required value={form.targetUserId} onChange={(event) => onChange("targetUserId", event.target.value)} />
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="目标用户 ID"
|
||||||
|
required
|
||||||
|
value={form.targetUserId}
|
||||||
|
onChange={(event) => onChange("targetUserId", event.target.value)}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
||||||
@ -417,7 +480,14 @@ function CreateDialog({
|
|||||||
value={form.coinAmount}
|
value={form.coinAmount}
|
||||||
onChange={(event) => onChange("coinAmount", event.target.value)}
|
onChange={(event) => onChange("coinAmount", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField disabled={disabled} label="支付渠道" required select value={form.providerCode} onChange={(event) => onChange("providerCode", event.target.value)}>
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="支付渠道"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.providerCode}
|
||||||
|
onChange={(event) => onChange("providerCode", event.target.value)}
|
||||||
|
>
|
||||||
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
|
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
{label}
|
{label}
|
||||||
@ -425,7 +495,14 @@ function CreateDialog({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{form.providerCode === "usdt" ? (
|
{form.providerCode === "usdt" ? (
|
||||||
<TextField disabled={disabled} label="USDT 链" required select value={form.chain} onChange={(event) => onChange("chain", event.target.value)}>
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="USDT 链"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.chain}
|
||||||
|
onChange={(event) => onChange("chain", event.target.value)}
|
||||||
|
>
|
||||||
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
|
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
{label}
|
{label}
|
||||||
@ -433,9 +510,45 @@ function CreateDialog({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
) : null}
|
) : null}
|
||||||
|
{form.providerCode !== "usdt" ? (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
disabled={disabled}
|
||||||
|
getOptionLabel={currencyOptionLabel}
|
||||||
|
isOptionEqualToValue={(option, value) => option === value}
|
||||||
|
noOptionsText="无匹配币种"
|
||||||
|
options={providerCurrencyOptions(form.providerCode)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="币种" required />
|
||||||
|
)}
|
||||||
|
value={form.providerCurrencyCode || null}
|
||||||
|
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0, step: "0.01" }}
|
||||||
|
label="币种金额"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={form.providerAmount}
|
||||||
|
onChange={(event) => onChange("providerAmount", event.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="finance-receipt-field">
|
<div className="finance-receipt-field">
|
||||||
<TextField disabled={disabled} label="三方订单号" required value={form.externalOrderNo} onChange={(event) => onChange("externalOrderNo", event.target.value)} />
|
<TextField
|
||||||
<Button disabled={disabled || !onVerifyReceipt} startIcon={<FactCheckOutlined fontSize="small" />} variant="outlined" onClick={onVerifyReceipt}>
|
disabled={disabled}
|
||||||
|
label="三方订单号"
|
||||||
|
required
|
||||||
|
value={form.externalOrderNo}
|
||||||
|
onChange={(event) => onChange("externalOrderNo", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={disabled || !onVerifyReceipt}
|
||||||
|
startIcon={<FactCheckOutlined fontSize="small" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onVerifyReceipt}
|
||||||
|
>
|
||||||
校验
|
校验
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -466,6 +579,14 @@ function isZeroCoinMakeupOrder(order) {
|
|||||||
return Number(order?.coinAmount || 0) === 0;
|
return Number(order?.coinAmount || 0) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerCurrencyOptions(providerCode) {
|
||||||
|
return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currencyOptionLabel(option) {
|
||||||
|
return option || "";
|
||||||
|
}
|
||||||
|
|
||||||
function ReceiptVerificationBanner({ verification }) {
|
function ReceiptVerificationBanner({ verification }) {
|
||||||
const detail = [
|
const detail = [
|
||||||
verification.status && `状态 ${verification.status}`,
|
verification.status && `状态 ${verification.status}`,
|
||||||
@ -486,7 +607,11 @@ function ReceiptVerificationBanner({ verification }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
return <span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>{coinSellerRechargeStatusLabel(status)}</span>;
|
return (
|
||||||
|
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
|
||||||
|
{coinSellerRechargeStatusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stacked({ primary, secondary }) {
|
function Stacked({ primary, secondary }) {
|
||||||
@ -523,7 +648,9 @@ function providerMeta(item) {
|
|||||||
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
|
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
|
||||||
}
|
}
|
||||||
if (item.providerCode === "v5pay") {
|
if (item.providerCode === "v5pay") {
|
||||||
return [item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-";
|
return (
|
||||||
|
[item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
@ -531,8 +658,12 @@ function providerMeta(item) {
|
|||||||
function walletMeta(item) {
|
function walletMeta(item) {
|
||||||
return [
|
return [
|
||||||
item.walletAssetType,
|
item.walletAssetType,
|
||||||
item.walletAmountDelta === null || item.walletAmountDelta === undefined ? "" : `变动 ${formatAmount(item.walletAmountDelta)}`,
|
item.walletAmountDelta === null || item.walletAmountDelta === undefined
|
||||||
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined ? "" : `余额 ${formatAmount(item.walletBalanceAfter)}`,
|
? ""
|
||||||
|
: `变动 ${formatAmount(item.walletAmountDelta)}`,
|
||||||
|
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined
|
||||||
|
? ""
|
||||||
|
: `余额 ${formatAmount(item.walletBalanceAfter)}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" / ");
|
.join(" / ");
|
||||||
|
|||||||
@ -9,26 +9,31 @@ afterEach(() => {
|
|||||||
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
||||||
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
||||||
const onVerifyReceipt = vi.fn(async () => ({
|
const onVerifyReceipt = vi.fn(async () => ({
|
||||||
currencyCode: "USD",
|
currencyCode: "PHP",
|
||||||
providerAmountMinor: 1000,
|
providerAmountMinor: 4800,
|
||||||
providerOrderId: "V5-001",
|
providerOrderId: "V5-001",
|
||||||
status: "paid",
|
status: "paid",
|
||||||
verified: true,
|
verified: true,
|
||||||
}));
|
}));
|
||||||
render(<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />);
|
render(
|
||||||
|
<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />,
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
|
||||||
fireEvent.click(screen.getByRole("option", { name: "V5Pay" }));
|
fireEvent.click(screen.getByRole("option", { name: "V5Pay" }));
|
||||||
|
|
||||||
expect(screen.queryByRole("textbox", { name: "三方国家码" })).not.toBeInTheDocument();
|
expect(screen.getByRole("combobox", { name: "币种" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("textbox", { name: "三方币种" })).not.toBeInTheDocument();
|
expect(screen.getByRole("spinbutton", { name: "币种金额" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("textbox", { name: "支付类型" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("textbox", { name: "支付类型" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("textbox", { name: "订单日期" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("textbox", { name: "订单日期" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
||||||
fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } });
|
fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } });
|
||||||
|
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
||||||
|
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } });
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
|
||||||
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
|
||||||
@ -39,7 +44,9 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
expect(onVerifyReceipt).toHaveBeenCalledWith({
|
expect(onVerifyReceipt).toHaveBeenCalledWith({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "V5-001",
|
externalOrderNo: "V5-001",
|
||||||
|
providerAmountMinor: 4800,
|
||||||
providerCode: "v5pay",
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
usdAmount: 10,
|
usdAmount: 10,
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "创建" }));
|
fireEvent.click(screen.getByRole("button", { name: "创建" }));
|
||||||
@ -48,7 +55,9 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 0,
|
coinAmount: 0,
|
||||||
externalOrderNo: "V5-001",
|
externalOrderNo: "V5-001",
|
||||||
|
providerAmountMinor: 4800,
|
||||||
providerCode: "v5pay",
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
remark: "补单,不发金币",
|
remark: "补单,不发金币",
|
||||||
targetUserId: "10001",
|
targetUserId: "10001",
|
||||||
usdAmount: 10,
|
usdAmount: 10,
|
||||||
@ -59,7 +68,10 @@ test("coin seller recharge order create dialog verifies receipt before create",
|
|||||||
test("coin seller recharge order actions follow verify and grant status", () => {
|
test("coin seller recharge order actions follow verify and grant status", () => {
|
||||||
const onVerify = vi.fn();
|
const onVerify = vi.fn();
|
||||||
const onGrant = vi.fn();
|
const onGrant = vi.fn();
|
||||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
vi.stubGlobal(
|
||||||
|
"confirm",
|
||||||
|
vi.fn(() => true),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FinanceCoinSellerRechargeOrderList
|
<FinanceCoinSellerRechargeOrderList
|
||||||
@ -100,7 +112,9 @@ test("zero coin seller recharge order uses makeup completion action", () => {
|
|||||||
<FinanceCoinSellerRechargeOrderList
|
<FinanceCoinSellerRechargeOrderList
|
||||||
{...baseProps({
|
{...baseProps({
|
||||||
orders: {
|
orders: {
|
||||||
items: [orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" })],
|
items: [
|
||||||
|
orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" }),
|
||||||
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
total: 1,
|
total: 1,
|
||||||
|
|||||||
@ -467,15 +467,8 @@ function coinText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function billAmountText(item) {
|
function billAmountText(item) {
|
||||||
if (item.billAmountText) {
|
// 账单金额是平台统一核算的 USDT 口径;currencyCode/providerAmountMinor 属于用户向三方渠道实付的本地币种,不能混到本列。
|
||||||
return item.billAmountText;
|
return formatUsdMinor(item.usdMinorAmount, "USDT");
|
||||||
}
|
|
||||||
const amount = hasNumber(item.billAmountMinor)
|
|
||||||
? item.billAmountMinor
|
|
||||||
: hasPositiveNumber(item.providerAmountMinor)
|
|
||||||
? item.providerAmountMinor
|
|
||||||
: item.usdMinorAmount;
|
|
||||||
return formatUsdMinor(amount, item.currencyCode || "USD");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function userPaidText(item) {
|
function userPaidText(item) {
|
||||||
|
|||||||
@ -76,10 +76,41 @@ test("renders app recharge coin columns and values when coin currency is selecte
|
|||||||
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
||||||
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("SAR 12.99")).toHaveLength(2);
|
expect(screen.getByText("USDT 9.99")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("SAR 12.99")).toBeInTheDocument();
|
||||||
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
|
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps bill amount in USDT while rendering third-party paid amount in local currency", () => {
|
||||||
|
render(
|
||||||
|
<FinanceRechargeDetailList
|
||||||
|
{...baseProps}
|
||||||
|
bills={{
|
||||||
|
...baseProps.bills,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...rechargeBillFixture(),
|
||||||
|
currencyCode: "EGP",
|
||||||
|
id: "tx-mifapay",
|
||||||
|
providerAmountMinor: 472600,
|
||||||
|
rechargeType: "third_party_recharge",
|
||||||
|
transactionId: "tx-mifapay",
|
||||||
|
usdMinorAmount: 10000,
|
||||||
|
userPaidAmountMicro: 4726000000,
|
||||||
|
userPaidCurrencyCode: "EGP",
|
||||||
|
userPaidText: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("USDT 100.00")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("EGP 4,726.00")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("EGP 100.00")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
||||||
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,11 @@ export const COIN_SELLER_RECHARGE_CHAINS = [
|
|||||||
["BSC", "BSC"],
|
["BSC", "BSC"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES = {
|
||||||
|
mifapay: ["PHP", "INR", "IDR", "VND", "SAR", "AED", "BHD", "QAR", "OMR", "KWD", "BDT", "PKR", "EGP"],
|
||||||
|
v5pay: ["PHP", "SAR", "EGP", "AED", "IDR", "INR", "PKR", "TRY", "MXN"],
|
||||||
|
};
|
||||||
|
|
||||||
export const COIN_SELLER_RECHARGE_STATUS = [
|
export const COIN_SELLER_RECHARGE_STATUS = [
|
||||||
["pending", "待处理"],
|
["pending", "待处理"],
|
||||||
["verified", "已校验"],
|
["verified", "已校验"],
|
||||||
@ -106,7 +111,9 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
|
|||||||
chain: "",
|
chain: "",
|
||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
externalOrderNo: "",
|
externalOrderNo: "",
|
||||||
|
providerAmount: "",
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
targetUserId: "",
|
targetUserId: "",
|
||||||
usdAmount: "",
|
usdAmount: "",
|
||||||
|
|||||||
@ -129,12 +129,16 @@ export function normalizeWithdrawalApplicationPage(data = {}) {
|
|||||||
|
|
||||||
export function buildCoinSellerRechargeOrderPayload(form) {
|
export function buildCoinSellerRechargeOrderPayload(form) {
|
||||||
const providerCode = stringValue(form.providerCode);
|
const providerCode = stringValue(form.providerCode);
|
||||||
|
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
|
||||||
|
const providerAmountMinor = providerAmountMinorFromForm(form);
|
||||||
const payload = {
|
const payload = {
|
||||||
appCode: stringValue(form.appCode),
|
appCode: stringValue(form.appCode),
|
||||||
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
||||||
coinAmount: numberValue(form.coinAmount),
|
coinAmount: numberValue(form.coinAmount),
|
||||||
externalOrderNo: stringValue(form.externalOrderNo),
|
externalOrderNo: stringValue(form.externalOrderNo),
|
||||||
|
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
||||||
providerCode,
|
providerCode,
|
||||||
|
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
||||||
remark: stringValue(form.remark),
|
remark: stringValue(form.remark),
|
||||||
targetUserId: stringValue(form.targetUserId),
|
targetUserId: stringValue(form.targetUserId),
|
||||||
usdAmount: numberValue(form.usdAmount),
|
usdAmount: numberValue(form.usdAmount),
|
||||||
@ -145,12 +149,16 @@ export function buildCoinSellerRechargeOrderPayload(form) {
|
|||||||
|
|
||||||
export function buildCoinSellerRechargeReceiptPayload(form) {
|
export function buildCoinSellerRechargeReceiptPayload(form) {
|
||||||
const providerCode = stringValue(form.providerCode);
|
const providerCode = stringValue(form.providerCode);
|
||||||
|
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
|
||||||
|
const providerAmountMinor = providerAmountMinorFromForm(form);
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries({
|
Object.entries({
|
||||||
appCode: stringValue(form.appCode),
|
appCode: stringValue(form.appCode),
|
||||||
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
|
||||||
externalOrderNo: stringValue(form.externalOrderNo),
|
externalOrderNo: stringValue(form.externalOrderNo),
|
||||||
|
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
|
||||||
providerCode,
|
providerCode,
|
||||||
|
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
|
||||||
usdAmount: numberValue(form.usdAmount),
|
usdAmount: numberValue(form.usdAmount),
|
||||||
}).filter(([, value]) => value !== ""),
|
}).filter(([, value]) => value !== ""),
|
||||||
);
|
);
|
||||||
@ -178,6 +186,14 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
|
|||||||
if (payload.providerCode === "usdt" && !payload.chain) {
|
if (payload.providerCode === "usdt" && !payload.chain) {
|
||||||
return "请选择 USDT 链";
|
return "请选择 USDT 链";
|
||||||
}
|
}
|
||||||
|
if (requiresThirdPartyProviderAmount(payload.providerCode)) {
|
||||||
|
if (!payload.providerCurrencyCode) {
|
||||||
|
return "请填写币种";
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(payload.providerAmountMinor) || payload.providerAmountMinor <= 0) {
|
||||||
|
return "请填写大于 0 的币种金额";
|
||||||
|
}
|
||||||
|
}
|
||||||
if (payload.remark && payload.remark.length > 512) {
|
if (payload.remark && payload.remark.length > 512) {
|
||||||
return "备注不能超过 512 字";
|
return "备注不能超过 512 字";
|
||||||
}
|
}
|
||||||
@ -642,6 +658,26 @@ function numberOrNull(value) {
|
|||||||
return Number.isFinite(nextValue) ? nextValue : null;
|
return Number.isFinite(nextValue) ? nextValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerMoneyToMinor(value) {
|
||||||
|
const number = numberValue(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
return Math.round(number * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerAmountMinorFromForm(form) {
|
||||||
|
// 表单输入是常规金额,已构建 payload 重用时则直接沿用 minor,避免校验请求二次放大金额。
|
||||||
|
if (stringValue(form.providerAmount) !== "") {
|
||||||
|
return providerMoneyToMinor(form.providerAmount);
|
||||||
|
}
|
||||||
|
return numberValue(form.providerAmountMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresThirdPartyProviderAmount(providerCode) {
|
||||||
|
return ["mifapay", "v5pay"].includes(stringValue(providerCode).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
function stringValue(value) {
|
function stringValue(value) {
|
||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,7 +170,9 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
appCode: " lalu ",
|
appCode: " lalu ",
|
||||||
coinAmount: "100000",
|
coinAmount: "100000",
|
||||||
externalOrderNo: " MIFA-001 ",
|
externalOrderNo: " MIFA-001 ",
|
||||||
|
providerAmount: "1250.5",
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: " php ",
|
||||||
remark: " 人工补单 ",
|
remark: " 人工补单 ",
|
||||||
targetUserId: " 10001 ",
|
targetUserId: " 10001 ",
|
||||||
usdAmount: "10.5",
|
usdAmount: "10.5",
|
||||||
@ -180,7 +182,9 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
coinAmount: 100000,
|
coinAmount: 100000,
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
remark: "人工补单",
|
remark: "人工补单",
|
||||||
targetUserId: "10001",
|
targetUserId: "10001",
|
||||||
usdAmount: 10.5,
|
usdAmount: 10.5,
|
||||||
@ -194,16 +198,90 @@ test("builds validates and normalizes coin seller recharge orders", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
appCode: "lalu",
|
appCode: "lalu",
|
||||||
externalOrderNo: "MIFA-001",
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerAmountMinor: 125050,
|
||||||
providerCode: "mifapay",
|
providerCode: "mifapay",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
usdAmount: 10.5,
|
usdAmount: 10.5,
|
||||||
});
|
});
|
||||||
expect(validateCoinSellerRechargeOrderPayload(payload)).toBe("");
|
expect(validateCoinSellerRechargeOrderPayload(payload)).toBe("");
|
||||||
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCurrencyCode: "" })).toBe("请填写币种");
|
||||||
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe(
|
||||||
|
"请填写大于 0 的币种金额",
|
||||||
|
);
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe("");
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe("");
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币");
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币");
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
|
||||||
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
|
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
|
||||||
"请选择 USDT 链",
|
"请选择 USDT 链",
|
||||||
);
|
);
|
||||||
|
const boundaryPayload = buildCoinSellerRechargeOrderPayload({
|
||||||
|
...payload,
|
||||||
|
externalOrderNo: "V5-001",
|
||||||
|
providerAmount: "0.01",
|
||||||
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "try",
|
||||||
|
});
|
||||||
|
expect(boundaryPayload).toMatchObject({
|
||||||
|
externalOrderNo: "V5-001",
|
||||||
|
providerAmountMinor: 1,
|
||||||
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "TRY",
|
||||||
|
});
|
||||||
|
expect(validateCoinSellerRechargeOrderPayload(boundaryPayload)).toBe("");
|
||||||
|
expect(
|
||||||
|
validateCoinSellerRechargeOrderPayload(
|
||||||
|
buildCoinSellerRechargeOrderPayload({
|
||||||
|
...payload,
|
||||||
|
providerAmount: "abc",
|
||||||
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "TRY",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("请填写大于 0 的币种金额");
|
||||||
|
expect(
|
||||||
|
validateCoinSellerRechargeOrderPayload(
|
||||||
|
buildCoinSellerRechargeOrderPayload({
|
||||||
|
...payload,
|
||||||
|
providerAmount: "",
|
||||||
|
providerAmountMinor: undefined,
|
||||||
|
providerCode: "v5pay",
|
||||||
|
providerCurrencyCode: "TRY",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("请填写大于 0 的币种金额");
|
||||||
|
expect(
|
||||||
|
buildCoinSellerRechargeOrderPayload({
|
||||||
|
...payload,
|
||||||
|
chain: "tron",
|
||||||
|
providerAmount: "48",
|
||||||
|
providerCode: "usdt",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
appCode: "lalu",
|
||||||
|
chain: "TRON",
|
||||||
|
coinAmount: 100000,
|
||||||
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerCode: "usdt",
|
||||||
|
remark: "人工补单",
|
||||||
|
targetUserId: "10001",
|
||||||
|
usdAmount: 10.5,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
buildCoinSellerRechargeReceiptPayload({
|
||||||
|
...payload,
|
||||||
|
chain: "bsc",
|
||||||
|
providerAmount: "48",
|
||||||
|
providerCode: "usdt",
|
||||||
|
providerCurrencyCode: "PHP",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
appCode: "lalu",
|
||||||
|
chain: "BSC",
|
||||||
|
externalOrderNo: "MIFA-001",
|
||||||
|
providerCode: "usdt",
|
||||||
|
usdAmount: 10.5,
|
||||||
|
});
|
||||||
|
|
||||||
const page = normalizeCoinSellerRechargeOrderPage({
|
const page = normalizeCoinSellerRechargeOrderPage({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
12
ops-center/index.html
Normal file
12
ops-center/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HYApp 运营中心</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="ops-center-root"></div>
|
||||||
|
<script type="module" src="/ops-center/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
165
ops-center/src/OpsCenterApp.jsx
Normal file
165
ops-center/src/OpsCenterApp.jsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
||||||
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
|
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||||
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||||
|
import { AppsView } from "./components/AppsView.jsx";
|
||||||
|
import { ConfigsView } from "./components/ConfigsView.jsx";
|
||||||
|
import { DrawsView } from "./components/DrawsView.jsx";
|
||||||
|
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||||
|
import { OpsShell } from "./components/OpsShell.jsx";
|
||||||
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||||
|
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
||||||
|
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
||||||
|
{ icon: AppsOutlined, key: "apps", label: "应用列表" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialDashboard = {
|
||||||
|
apps: [],
|
||||||
|
appSummaries: [],
|
||||||
|
luckyGifts: [],
|
||||||
|
poolBalances: [],
|
||||||
|
summary: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OpsCenterApp() {
|
||||||
|
const [activeView, setActiveView] = useState("overview");
|
||||||
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
|
const [dialog, setDialog] = useState(null);
|
||||||
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await fetchOpsDashboard();
|
||||||
|
setState({ data, error: "", loaded: true, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
// 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。
|
||||||
|
setState((current) => ({
|
||||||
|
data: current.data || initialDashboard,
|
||||||
|
error: error.message || "运营接口暂不可用",
|
||||||
|
loaded: current.loaded,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const { data } = state;
|
||||||
|
|
||||||
|
const appOptions = useMemo(
|
||||||
|
() => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
||||||
|
[data.apps]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。
|
||||||
|
const poolIDsByApp = useMemo(() => {
|
||||||
|
const byApp = new Map();
|
||||||
|
const add = (appCode, poolID) => {
|
||||||
|
if (!appCode || !poolID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!byApp.has(appCode)) {
|
||||||
|
byApp.set(appCode, new Set());
|
||||||
|
}
|
||||||
|
byApp.get(appCode).add(poolID);
|
||||||
|
};
|
||||||
|
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
|
||||||
|
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
|
||||||
|
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
|
||||||
|
}, [data.luckyGifts, data.poolBalances]);
|
||||||
|
|
||||||
|
const openCreateConfig = useCallback(() => {
|
||||||
|
// 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置),
|
||||||
|
// 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。
|
||||||
|
const template = data.luckyGifts[0];
|
||||||
|
if (!template) {
|
||||||
|
showToast("暂无可添加配置的应用", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialog({
|
||||||
|
config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 },
|
||||||
|
mode: "create"
|
||||||
|
});
|
||||||
|
}, [data.luckyGifts, showToast]);
|
||||||
|
|
||||||
|
const openEditConfig = useCallback((config) => {
|
||||||
|
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback(
|
||||||
|
async (config) => {
|
||||||
|
setSavingConfig(true);
|
||||||
|
try {
|
||||||
|
await saveLuckyGiftConfig(config);
|
||||||
|
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
|
||||||
|
setDialog(null);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
// 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||||||
|
showToast(error.message || "保存幸运礼物配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingConfig(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadDashboard, showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
||||||
|
<div className="ops-page">
|
||||||
|
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||||||
|
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
|
||||||
|
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</PageHead>
|
||||||
|
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
||||||
|
{state.error && state.loaded ? (
|
||||||
|
<div className="ops-alert" role="alert">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}>
|
||||||
|
{activeView === "overview" ? <OverviewView data={data} /> : null}
|
||||||
|
{activeView === "configs" ? (
|
||||||
|
<ConfigsView
|
||||||
|
apps={data.apps}
|
||||||
|
luckyGifts={data.luckyGifts}
|
||||||
|
saving={savingConfig}
|
||||||
|
onCreate={openCreateConfig}
|
||||||
|
onEdit={openEditConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null}
|
||||||
|
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||||||
|
</DataState>
|
||||||
|
{dialog ? (
|
||||||
|
<LuckyGiftConfigDialog
|
||||||
|
appOptions={appOptions}
|
||||||
|
config={dialog.config}
|
||||||
|
mode={dialog.mode}
|
||||||
|
saving={savingConfig}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
onSubmit={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</OpsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
ops-center/src/OpsCenterApp.test.jsx
Normal file
176
ops-center/src/OpsCenterApp.test.jsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||||
|
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
|
||||||
|
vi.mock("./api.js", () => ({
|
||||||
|
DEFAULT_POOL_ID: "default",
|
||||||
|
fetchLuckyGiftDraws: vi.fn(),
|
||||||
|
fetchOpsDashboard: vi.fn(),
|
||||||
|
saveLuckyGiftConfig: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||||||
|
fetchLuckyGiftDraws.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
created_at_ms: 1767000000000,
|
||||||
|
draw_id: "draw-1",
|
||||||
|
effective_reward_coins: 100,
|
||||||
|
external_user_id: "ext-1",
|
||||||
|
gift_id: "gift-9",
|
||||||
|
multiplier_ppm: 2_000_000,
|
||||||
|
pool_id: "super_lucky",
|
||||||
|
reward_status: "granted",
|
||||||
|
rule_version: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1
|
||||||
|
});
|
||||||
|
fetchOpsDashboard.mockResolvedValue({
|
||||||
|
apps: [
|
||||||
|
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||||
|
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }
|
||||||
|
],
|
||||||
|
appSummaries: [
|
||||||
|
{
|
||||||
|
actual_rtp_ppm: 900000,
|
||||||
|
app_code: "lalu",
|
||||||
|
failed_draws: 0,
|
||||||
|
granted_draws: 3,
|
||||||
|
pending_draws: 0,
|
||||||
|
total_draws: 3,
|
||||||
|
total_reward_coins: 900,
|
||||||
|
total_spent_coins: 1000,
|
||||||
|
unique_rooms: 2,
|
||||||
|
unique_users: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
luckyGifts: [
|
||||||
|
{ app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
||||||
|
{ app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
|
||||||
|
{ app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }
|
||||||
|
],
|
||||||
|
poolBalances: [
|
||||||
|
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 },
|
||||||
|
{ app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
activeApps: 2,
|
||||||
|
configuredPools: 2,
|
||||||
|
enabledPools: 1,
|
||||||
|
failedDraws: 0,
|
||||||
|
grantedDraws: 3,
|
||||||
|
pendingDraws: 0,
|
||||||
|
poolAvailableTotal: 38311517,
|
||||||
|
poolBalanceTotal: 38941517,
|
||||||
|
poolReserveTotal: 630000,
|
||||||
|
totalDraws: 3,
|
||||||
|
totalRewardCoins: 900,
|
||||||
|
totalSpentCoins: 1000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<OpsCenterApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders overview with aggregated kpis and pool balances", async () => {
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
|
||||||
|
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
|
||||||
|
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("运营应用")).toBeTruthy();
|
||||||
|
// 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。
|
||||||
|
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("奖池实时水位")).toBeTruthy();
|
||||||
|
expect(screen.getByText("已入账")).toBeTruthy();
|
||||||
|
expect(screen.getByText("默认水位")).toBeTruthy();
|
||||||
|
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists every pool config including published non-default pools", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
|
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
|
||||||
|
expect(await screen.findByText("lucky")).toBeTruthy();
|
||||||
|
expect(screen.getByText("super_lucky")).toBeTruthy();
|
||||||
|
expect(screen.getByText("v3")).toBeTruthy();
|
||||||
|
expect(screen.getByText("v2")).toBeTruthy();
|
||||||
|
expect(screen.getByText("已启用")).toBeTruthy();
|
||||||
|
expect(screen.getByText("已停用")).toBeTruthy();
|
||||||
|
expect(screen.getByText("默认草稿")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens config dialog from a published pool row and saves a new version", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
await screen.findByText("lucky");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||||||
|
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
app_code: "lalu",
|
||||||
|
pool_id: "lucky",
|
||||||
|
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
|
||||||
|
target_rtp_percent: 88
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads draw records for the first app with server pagination", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("ext-1")).toBeTruthy();
|
||||||
|
expect(screen.getByText("已发放")).toBeTruthy();
|
||||||
|
expect(screen.getByText("2x")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows app registry and fixed integrations in apps view", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Lalu")).toBeTruthy();
|
||||||
|
expect(screen.getByText("主数据")).toBeTruthy();
|
||||||
|
expect(screen.getByText("外部接入")).toBeTruthy();
|
||||||
|
});
|
||||||
264
ops-center/src/api.js
Normal file
264
ops-center/src/api.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { getAccessToken } from "@/shared/api/request";
|
||||||
|
|
||||||
|
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
||||||
|
export const DEFAULT_POOL_ID = "default";
|
||||||
|
|
||||||
|
export async function fetchOpsDashboard(query = {}) {
|
||||||
|
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
|
||||||
|
// lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。
|
||||||
|
const appsPayload = await opsRequest("/apps", { query });
|
||||||
|
const apps = normalizeItems(appsPayload);
|
||||||
|
const appCodes = uniqueAppCodes(apps);
|
||||||
|
|
||||||
|
const perApp = await Promise.all(
|
||||||
|
appCodes.map(async (appCode) => {
|
||||||
|
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
||||||
|
fetchAppLuckyGiftConfigs(appCode),
|
||||||
|
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||||
|
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } })
|
||||||
|
]);
|
||||||
|
return { appCode, drawSummary, luckyGifts, poolBalances };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeDashboard({ apps, perApp });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAppLuckyGiftConfigs(appCode) {
|
||||||
|
// 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。
|
||||||
|
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
|
||||||
|
const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } }))
|
||||||
|
.map((config) => normalizeLuckyGiftConfig(config, appCode))
|
||||||
|
.filter((config) => config.app_code);
|
||||||
|
if (published.length) {
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
|
||||||
|
// 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。
|
||||||
|
const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode);
|
||||||
|
return draft.app_code ? [draft] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) {
|
||||||
|
const query = {
|
||||||
|
app_code: appCode,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
pool_id: poolId,
|
||||||
|
status
|
||||||
|
};
|
||||||
|
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配,
|
||||||
|
// 其余走 external_user_id,避免运营要先分辨用户来源再选字段。
|
||||||
|
const trimmedUserID = String(userId || "").trim();
|
||||||
|
if (/^\d+$/.test(trimmedUserID)) {
|
||||||
|
query.user_id = trimmedUserID;
|
||||||
|
} else if (trimmedUserID) {
|
||||||
|
query.external_user_id = trimmedUserID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await opsRequest("/lucky-gifts/draws", { query });
|
||||||
|
return {
|
||||||
|
items: normalizeItems(payload),
|
||||||
|
page: numberValue(payload.page) || page,
|
||||||
|
pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
|
||||||
|
total: numberValue(payload.total)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLuckyGiftConfig(config = {}) {
|
||||||
|
const payload = luckyGiftConfigPayload(config);
|
||||||
|
return opsRequest("/lucky-gifts/config", {
|
||||||
|
body: payload,
|
||||||
|
method: "PUT",
|
||||||
|
query: {
|
||||||
|
app_code: payload.app_code,
|
||||||
|
pool_id: payload.pool_id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||||||
|
const response = await fetch(buildOpsUrl(path, query), {
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
credentials: "include",
|
||||||
|
headers: buildHeaders(body),
|
||||||
|
method
|
||||||
|
});
|
||||||
|
const payload = await readPayload(response);
|
||||||
|
|
||||||
|
if (!response.ok || payload.code !== 0) {
|
||||||
|
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpsUrl(path, query = {}) {
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);
|
||||||
|
|
||||||
|
Object.entries(query || {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||||
|
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||||
|
const poolBalances = perApp
|
||||||
|
.flatMap((entry) => entry.poolBalances || [])
|
||||||
|
.filter((item) => item?.app_code || item?.appCode);
|
||||||
|
const appSummaries = perApp
|
||||||
|
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||||||
|
.filter((summary) => summary.app_code);
|
||||||
|
|
||||||
|
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
||||||
|
|
||||||
|
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
|
||||||
|
const summary = {
|
||||||
|
activeApps: apps.length,
|
||||||
|
configuredPools: publishedConfigs.length,
|
||||||
|
enabledPools: publishedConfigs.filter((config) => config.enabled).length,
|
||||||
|
failedDraws: sumBy(appSummaries, "failed_draws"),
|
||||||
|
grantedDraws: sumBy(appSummaries, "granted_draws"),
|
||||||
|
pendingDraws: sumBy(appSummaries, "pending_draws"),
|
||||||
|
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
|
||||||
|
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
||||||
|
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
||||||
|
totalDraws: sumBy(appSummaries, "total_draws"),
|
||||||
|
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
||||||
|
totalSpentCoins: sumBy(appSummaries, "total_spent_coins")
|
||||||
|
};
|
||||||
|
|
||||||
|
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDrawSummary(summary = {}, appCode = "") {
|
||||||
|
return {
|
||||||
|
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
|
||||||
|
app_code: String(appCode || summary.app_code || "").trim().toLowerCase(),
|
||||||
|
failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
|
||||||
|
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
|
||||||
|
pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
|
||||||
|
total_draws: numberValue(summary.total_draws ?? summary.totalDraws),
|
||||||
|
total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
|
||||||
|
total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
|
||||||
|
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
|
||||||
|
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueAppCodes(apps) {
|
||||||
|
const seen = new Set();
|
||||||
|
const codes = [];
|
||||||
|
apps.forEach((item) => {
|
||||||
|
const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase();
|
||||||
|
if (!code || seen.has(code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(code);
|
||||||
|
codes.push(code);
|
||||||
|
});
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
|
||||||
|
const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase();
|
||||||
|
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
app_code: appCode,
|
||||||
|
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
|
||||||
|
is_default: ruleVersion <= 0,
|
||||||
|
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
||||||
|
rule_version: ruleVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function luckyGiftConfigPayload(config = {}) {
|
||||||
|
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase();
|
||||||
|
const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
||||||
|
return {
|
||||||
|
app_code: appCode,
|
||||||
|
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
|
||||||
|
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS),
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
|
||||||
|
normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
|
||||||
|
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
|
||||||
|
pool_id: poolID,
|
||||||
|
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
|
||||||
|
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
|
||||||
|
stages: normalizeStages(config.stages),
|
||||||
|
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(body) {
|
||||||
|
const headers = {};
|
||||||
|
const token = getAccessToken();
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return { code: response.ok ? 0 : response.status, data: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItems(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value?.items)) {
|
||||||
|
return value.items;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value?.list)) {
|
||||||
|
return value.list;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumBy(items, key, fallbackKey) {
|
||||||
|
return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStages(stages) {
|
||||||
|
if (!Array.isArray(stages)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return stages.map((stage) => ({
|
||||||
|
stage: stage.stage || stage.Stage || "",
|
||||||
|
tiers: Array.isArray(stage.tiers)
|
||||||
|
? stage.tiers.map((tier) => ({
|
||||||
|
enabled: tier.enabled !== false,
|
||||||
|
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||||
|
multiplier: numberValue(tier.multiplier),
|
||||||
|
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
|
||||||
|
tier_id: tier.tier_id || tier.tierId || ""
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}));
|
||||||
|
}
|
||||||
146
ops-center/src/api.test.js
Normal file
146
ops-center/src/api.test.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(data) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("builds ops api urls under the dedicated prefix", () => {
|
||||||
|
const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });
|
||||||
|
|
||||||
|
expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
|
||||||
|
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads every pool config per app through the plural configs endpoint", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
|
const text = String(url);
|
||||||
|
calls.push(text);
|
||||||
|
if (text.endsWith("/apps")) {
|
||||||
|
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/configs?")) {
|
||||||
|
// lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。
|
||||||
|
return jsonResponse([
|
||||||
|
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
||||||
|
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/pools?")) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 },
|
||||||
|
{ app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/summary?")) {
|
||||||
|
return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 });
|
||||||
|
}
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await fetchOpsDashboard();
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/apps",
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu"
|
||||||
|
]);
|
||||||
|
expect(data.luckyGifts).toEqual([
|
||||||
|
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
||||||
|
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 })
|
||||||
|
]);
|
||||||
|
expect(data.summary).toMatchObject({
|
||||||
|
activeApps: 1,
|
||||||
|
configuredPools: 2,
|
||||||
|
enabledPools: 1,
|
||||||
|
poolBalanceTotal: 38941517,
|
||||||
|
totalDraws: 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the server default draft when an app has no published config", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
|
const text = String(url);
|
||||||
|
calls.push(text);
|
||||||
|
if (text.endsWith("/apps")) {
|
||||||
|
return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] });
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/configs?")) {
|
||||||
|
return jsonResponse([]);
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/config?")) {
|
||||||
|
return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 });
|
||||||
|
}
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await fetchOpsDashboard();
|
||||||
|
|
||||||
|
expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
|
||||||
|
expect(data.luckyGifts).toEqual([
|
||||||
|
expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
|
calls.push(String(url));
|
||||||
|
return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" });
|
||||||
|
await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" });
|
||||||
|
|
||||||
|
const numericParams = new URL(calls[0]).searchParams;
|
||||||
|
expect(numericParams.get("app_code")).toBe("lalu");
|
||||||
|
expect(numericParams.get("pool_id")).toBe("lucky");
|
||||||
|
expect(numericParams.get("user_id")).toBe("10001");
|
||||||
|
expect(numericParams.get("external_user_id")).toBeNull();
|
||||||
|
const externalParams = new URL(calls[1]).searchParams;
|
||||||
|
expect(externalParams.get("external_user_id")).toBe("ext-abc");
|
||||||
|
expect(externalParams.get("user_id")).toBeNull();
|
||||||
|
expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves lucky gift config through admin ops route", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
|
calls.push({ body: init.body, method: init.method, url: String(url) });
|
||||||
|
return jsonResponse({ app_code: "aslan", pool_id: "default" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveLuckyGiftConfig({
|
||||||
|
app_code: "aslan",
|
||||||
|
pool_id: "default",
|
||||||
|
enabled: true,
|
||||||
|
target_rtp_percent: 95,
|
||||||
|
pool_rate_percent: 96,
|
||||||
|
settlement_window_wager: 1_000_000,
|
||||||
|
control_band_percent: 1,
|
||||||
|
gift_price_reference: 100,
|
||||||
|
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls[0].method).toBe("PUT");
|
||||||
|
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default");
|
||||||
|
expect(JSON.parse(calls[0].body)).toMatchObject({
|
||||||
|
app_code: "aslan",
|
||||||
|
enabled: true,
|
||||||
|
pool_id: "default",
|
||||||
|
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }],
|
||||||
|
target_rtp_percent: 95
|
||||||
|
});
|
||||||
|
});
|
||||||
97
ops-center/src/components/AppsView.jsx
Normal file
97
ops-center/src/components/AppsView.jsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function AppsView({ apps }) {
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
|
||||||
|
// 应用主数据总量很小(个位数),筛选全部在前端完成,不回源。
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
return apps.filter((item) => {
|
||||||
|
const code = String(item.app_code ?? item.appCode ?? "").toLowerCase();
|
||||||
|
const name = String(item.app_name ?? item.appName ?? "").toLowerCase();
|
||||||
|
if (normalizedKeyword && !code.includes(normalizedKeyword) && !name.includes(normalizedKeyword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status && String(item.status || "").toLowerCase() !== status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [apps, keyword, status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-view">
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminSearchBox label="应用" placeholder="应用编码 / 名称" value={keyword} onChange={setKeyword} />
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="状态"
|
||||||
|
options={[
|
||||||
|
["", "全部状态"],
|
||||||
|
["active", "已启用"],
|
||||||
|
["disabled", "已停用"]
|
||||||
|
]}
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!keyword && !status}
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword("");
|
||||||
|
setStatus("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={appColumns}
|
||||||
|
emptyLabel="暂无应用"
|
||||||
|
items={items}
|
||||||
|
minWidth="880px"
|
||||||
|
rowKey={(item) => item.app_code ?? item.appCode}
|
||||||
|
title="应用列表"
|
||||||
|
total={items.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appColumns = [
|
||||||
|
{ key: "app_code", label: "应用编码", render: (item) => item.app_code ?? item.appCode, width: "minmax(100px, 0.8fr)" },
|
||||||
|
{ key: "app_name", label: "应用名称", render: (item) => item.app_name ?? item.appName },
|
||||||
|
{ key: "platform", label: "平台", render: (item) => item.platform || "-", width: "minmax(80px, 0.6fr)" },
|
||||||
|
{ key: "package_name", label: "包名", render: (item) => item.package_name ?? item.packageName ?? "-", width: "minmax(180px, 1.4fr)" },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) =>
|
||||||
|
String(item.status || "").toLowerCase() === "active" ? (
|
||||||
|
<OpsStatusBadge label="已启用" tone="succeeded" />
|
||||||
|
) : (
|
||||||
|
<OpsStatusBadge label={item.status || "-"} />
|
||||||
|
),
|
||||||
|
width: "minmax(112px, 0.6fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
label: "来源",
|
||||||
|
// fixed 来源是 admin-server 为外部幸运礼物接入硬编码补齐的白名单 App(yumi/aslan),
|
||||||
|
// 不在 app registry 主数据里,运营需要知道它们没有包名/平台等完整信息。
|
||||||
|
render: (item) =>
|
||||||
|
item.source === "fixed" ? <OpsStatusBadge label="外部接入" tone="warning" /> : <OpsStatusBadge label="主数据" tone="succeeded" />,
|
||||||
|
width: "minmax(124px, 0.7fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updated_at_ms",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => (item.source === "fixed" ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />),
|
||||||
|
width: "minmax(150px, 1fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
90
ops-center/src/components/ConfigsView.jsx
Normal file
90
ops-center/src/components/ConfigsView.jsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||||
|
const [appFilter, setAppFilter] = useState("");
|
||||||
|
|
||||||
|
const appOptions = useMemo(
|
||||||
|
() => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
||||||
|
[apps]
|
||||||
|
);
|
||||||
|
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||||
|
const items = useMemo(
|
||||||
|
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
||||||
|
[appFilter, luckyGifts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-view">
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
|
||||||
|
添加配置
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="暂无幸运礼物配置"
|
||||||
|
items={items}
|
||||||
|
minWidth="1080px"
|
||||||
|
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
||||||
|
title="幸运礼物配置"
|
||||||
|
total={items.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns({ onEdit, saving }) {
|
||||||
|
return [
|
||||||
|
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||||
|
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
||||||
|
{
|
||||||
|
key: "rule_version",
|
||||||
|
label: "版本",
|
||||||
|
render: (item) => (item.is_default ? "-" : `v${item.rule_version}`),
|
||||||
|
width: "minmax(72px, 0.5fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enabled",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => {
|
||||||
|
// 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。
|
||||||
|
if (item.is_default) {
|
||||||
|
return <OpsStatusBadge label="默认草稿" tone="warning" />;
|
||||||
|
}
|
||||||
|
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />;
|
||||||
|
},
|
||||||
|
width: "minmax(128px, 0.8fr)"
|
||||||
|
},
|
||||||
|
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
|
||||||
|
{ key: "pool_rate_percent", label: "奖池回收", render: (item) => formatPercent(item.pool_rate_percent) },
|
||||||
|
{ key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) },
|
||||||
|
{ key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) },
|
||||||
|
{
|
||||||
|
key: "created_at_ms",
|
||||||
|
label: "发布时间",
|
||||||
|
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
||||||
|
width: "minmax(176px, 1.1fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item) => (
|
||||||
|
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
||||||
|
{item.is_default ? "发布配置" : "新增版本"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
169
ops-center/src/components/DrawsView.jsx
Normal file
169
ops-center/src/components/DrawsView.jsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
||||||
|
import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { fetchLuckyGiftDraws } from "../api.js";
|
||||||
|
import { formatMultiplierFromPPM, formatNumber } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const initialFilters = { poolId: "", status: "", userId: "" };
|
||||||
|
|
||||||
|
export function DrawsView({ apps, defaultAppCode, poolIDsByApp }) {
|
||||||
|
// 抽奖流水量大且服务端分页,必须选定应用后按需拉取,不能像配置那样一次拉全。
|
||||||
|
const [appCode, setAppCode] = useState(defaultAppCode);
|
||||||
|
const [filters, setFilters] = useState(initialFilters);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [state, setState] = useState({ error: "", items: [], loading: false, total: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 首屏 dashboard 加载完成前 defaultAppCode 为空,就绪后自动选中第一个应用。
|
||||||
|
setAppCode((current) => current || defaultAppCode);
|
||||||
|
}, [defaultAppCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appCode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
fetchLuckyGiftDraws({ appCode, page, pageSize: PAGE_SIZE, poolId: filters.poolId, status: filters.status, userId: filters.userId })
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((current) => ({
|
||||||
|
error: "",
|
||||||
|
// 滚动加载:翻页时向已加载列表追加;筛选变化会把 page 重置为 1,从头替换。
|
||||||
|
items: page > 1 ? [...current.items, ...result.items] : result.items,
|
||||||
|
loading: false,
|
||||||
|
total: result.total
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((current) => ({ ...current, error: error.message || "获取抽奖记录失败", loading: false }));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appCode, filters, page]);
|
||||||
|
|
||||||
|
const changeAppCode = useCallback((nextAppCode) => {
|
||||||
|
setAppCode(nextAppCode);
|
||||||
|
// 奖池归属于应用,切换应用后旧奖池筛选一定失效,一并重置。
|
||||||
|
setFilters(initialFilters);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeFilter = useCallback((key, value) => {
|
||||||
|
setFilters((current) => ({ ...current, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters(initialFilters);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appOptions = useMemo(() => apps.map((item) => [item.app_code ?? item.appCode, item.app_code ?? item.appCode]), [apps]);
|
||||||
|
const poolOptions = useMemo(() => (poolIDsByApp.get(appCode) || []).map((poolID) => [poolID, poolID]), [appCode, poolIDsByApp]);
|
||||||
|
const hasActiveFilters = Boolean(filters.poolId || filters.status || filters.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-view">
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminFilterSelect label="应用" options={appOptions} value={appCode || ""} onChange={changeAppCode} />
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="奖池"
|
||||||
|
options={[["", "全部奖池"], ...poolOptions]}
|
||||||
|
value={filters.poolId}
|
||||||
|
onChange={(value) => changeFilter("poolId", value)}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="发放状态"
|
||||||
|
options={[["", "全部状态"], ...drawStatusOptions]}
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(value) => changeFilter("status", value)}
|
||||||
|
/>
|
||||||
|
<AdminSearchBox
|
||||||
|
label="用户"
|
||||||
|
placeholder="用户 ID / 外部用户 ID"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(value) => changeFilter("userId", value)}
|
||||||
|
/>
|
||||||
|
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{state.error ? <div className="ops-alert" role="alert">{state.error}</div> : null}
|
||||||
|
<DataTable
|
||||||
|
columns={drawColumns}
|
||||||
|
emptyLabel={state.loading ? "加载中..." : "暂无抽奖记录"}
|
||||||
|
items={state.items}
|
||||||
|
minWidth="1080px"
|
||||||
|
pagination={{ onPageChange: setPage, page, total: state.total }}
|
||||||
|
rowKey={(item) => item.draw_id ?? item.drawId}
|
||||||
|
title="抽奖记录"
|
||||||
|
total={state.total}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawColumns = [
|
||||||
|
{
|
||||||
|
key: "created_at_ms",
|
||||||
|
label: "抽奖时间",
|
||||||
|
render: (item) => <TimeText value={item.created_at_ms ?? item.createdAtMs} />,
|
||||||
|
width: "minmax(150px, 1fr)"
|
||||||
|
},
|
||||||
|
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(80px, 0.6fr)" },
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户",
|
||||||
|
// 外部接入应用只有 external_user_id,内部应用只有数字 user_id,两者互斥,取有值的一侧。
|
||||||
|
render: (item) => item.external_user_id || item.externalUserId || item.user_id || item.userId || "-",
|
||||||
|
width: "minmax(120px, 1fr)"
|
||||||
|
},
|
||||||
|
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(100px, 0.8fr)" },
|
||||||
|
{ key: "gift_id", label: "礼物", render: (item) => item.gift_id ?? item.giftId, width: "minmax(100px, 0.8fr)" },
|
||||||
|
{
|
||||||
|
key: "multiplier_ppm",
|
||||||
|
label: "命中倍率",
|
||||||
|
render: (item) => formatMultiplierFromPPM(item.multiplier_ppm ?? item.multiplierPpm),
|
||||||
|
width: "minmax(88px, 0.6fr)"
|
||||||
|
},
|
||||||
|
{ key: "effective_reward_coins", label: "中奖金币", render: (item) => formatNumber(item.effective_reward_coins ?? item.effectiveRewardCoins) },
|
||||||
|
{
|
||||||
|
key: "reward_status",
|
||||||
|
label: "发放状态",
|
||||||
|
render: (item) => renderRewardStatus(item.reward_status ?? item.rewardStatus),
|
||||||
|
width: "minmax(124px, 0.8fr)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rule_version",
|
||||||
|
label: "规则版本",
|
||||||
|
render: (item) => `v${item.rule_version ?? item.ruleVersion ?? "-"}`,
|
||||||
|
width: "minmax(80px, 0.5fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderRewardStatus(status) {
|
||||||
|
if (status === "granted") {
|
||||||
|
return <OpsStatusBadge label="已发放" tone="succeeded" />;
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return <OpsStatusBadge label="发放失败" tone="danger" />;
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return <OpsStatusBadge label="发放中" tone="warning" />;
|
||||||
|
}
|
||||||
|
return <OpsStatusBadge label={status || "-"} />;
|
||||||
|
}
|
||||||
294
ops-center/src/components/LuckyGiftConfigDialog.jsx
Normal file
294
ops-center/src/components/LuckyGiftConfigDialog.jsx
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
||||||
|
import {
|
||||||
|
configToForm,
|
||||||
|
correctAllStageProbabilities,
|
||||||
|
formToConfig,
|
||||||
|
nextStageMultiplier,
|
||||||
|
stageSummary
|
||||||
|
} from "../configForm.js";
|
||||||
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||||||
|
const [form, setForm] = useState(() => configToForm(config));
|
||||||
|
const [activeStage, setActiveStage] = useState("novice");
|
||||||
|
|
||||||
|
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
||||||
|
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage;
|
||||||
|
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
|
||||||
|
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
|
||||||
|
|
||||||
|
const updateField = (field, value) => {
|
||||||
|
setForm((current) => {
|
||||||
|
const next = { ...current, [field]: value };
|
||||||
|
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||||
|
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStageTier = (stageKey, tierIndex, patch) => {
|
||||||
|
setForm((current) =>
|
||||||
|
correctAllStageProbabilities({
|
||||||
|
...current,
|
||||||
|
stages: current.stages.map((stage) =>
|
||||||
|
stage.stage === stageKey
|
||||||
|
? {
|
||||||
|
...stage,
|
||||||
|
tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier))
|
||||||
|
}
|
||||||
|
: stage
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStageTier = (stageKey) => {
|
||||||
|
setForm((current) =>
|
||||||
|
correctAllStageProbabilities({
|
||||||
|
...current,
|
||||||
|
stages: current.stages.map((stage) =>
|
||||||
|
stage.stage === stageKey
|
||||||
|
? {
|
||||||
|
...stage,
|
||||||
|
tiers: [
|
||||||
|
...stage.tiers,
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
// 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
|
||||||
|
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||||||
|
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||||||
|
probabilityPercent: "0",
|
||||||
|
tierId: ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: stage
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStageTier = (stageKey, tierIndex) => {
|
||||||
|
setForm((current) =>
|
||||||
|
correctAllStageProbabilities({
|
||||||
|
...current,
|
||||||
|
stages: current.stages.map((stage) =>
|
||||||
|
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(formToConfig(config, form));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreate = mode === "create";
|
||||||
|
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
|
||||||
|
<form className="ops-config-form" onSubmit={handleSubmit}>
|
||||||
|
<DialogTitle className="ops-config-title">
|
||||||
|
<span>{title}</span>
|
||||||
|
<small>
|
||||||
|
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
||||||
|
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
|
||||||
|
</small>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<div className="ops-config-metrics">
|
||||||
|
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||||||
|
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||||||
|
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
||||||
|
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
|
||||||
|
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
|
||||||
|
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ops-config-layout">
|
||||||
|
<aside className="ops-config-params" aria-label="基础参数">
|
||||||
|
<section className="ops-config-section">
|
||||||
|
<h3>规则身份</h3>
|
||||||
|
{isCreate ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
label="应用"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={form.app_code}
|
||||||
|
onChange={(event) => updateField("app_code", event.target.value)}
|
||||||
|
>
|
||||||
|
{appOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
|
||||||
|
label="奖池"
|
||||||
|
size="small"
|
||||||
|
value={form.pool_id}
|
||||||
|
onChange={(event) => updateField("pool_id", event.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
|
||||||
|
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||||||
|
sx={{ gap: 1, marginLeft: 0 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ops-config-section">
|
||||||
|
<h3>RTP 与奖池</h3>
|
||||||
|
<div className="ops-form-grid">
|
||||||
|
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
|
||||||
|
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
|
||||||
|
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
|
||||||
|
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
|
||||||
|
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||||
|
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||||
|
{stageSummaries.map((summary) => (
|
||||||
|
<button
|
||||||
|
aria-selected={summary.stageKey === activeStageKey}
|
||||||
|
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
|
||||||
|
key={summary.stageKey}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveStage(summary.stageKey)}
|
||||||
|
>
|
||||||
|
<span>{summary.tabLabel}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeStageConfig ? (
|
||||||
|
<div className="ops-stage-stack">
|
||||||
|
<div className="ops-stage-block__head">
|
||||||
|
<div>
|
||||||
|
<h3>{activeSummary?.stageLabel}</h3>
|
||||||
|
<OpsStatusBadge
|
||||||
|
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
|
||||||
|
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
|
||||||
|
添加奖档
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="ops-tier-table">
|
||||||
|
<div className="ops-tier-head" aria-hidden="true">
|
||||||
|
<span>倍率</span>
|
||||||
|
<span>概率 %</span>
|
||||||
|
<span>高水位</span>
|
||||||
|
<span>启用</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{activeStageConfig.tiers.map((tier, index) => (
|
||||||
|
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
|
||||||
|
type="number"
|
||||||
|
value={tier.multiplier}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStageTier(activeStageConfig.stage, index, {
|
||||||
|
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
||||||
|
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||||
|
multiplier: event.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
|
||||||
|
value={formatDecimal(tier.probabilityPercent)}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(tier.highWaterOnly)}
|
||||||
|
disabled={Number(tier.multiplier) >= 10}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ input: { "aria-label": "高水位" } }}
|
||||||
|
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={tier.enabled !== false}
|
||||||
|
size="small"
|
||||||
|
slotProps={{ input: { "aria-label": "启用" } }}
|
||||||
|
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={activeStageConfig.tiers.length <= 1}
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => removeStageTier(activeStageConfig.stage, index)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={saving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={saving} type="submit" variant="primary">
|
||||||
|
{saving ? "保存中" : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<article className="ops-config-metric">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ form, label, name, onChange, step = "1" }) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label={label}
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step } }}
|
||||||
|
type="number"
|
||||||
|
value={form[name]}
|
||||||
|
onChange={(event) => onChange(name, event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
ops-center/src/components/OpsShell.jsx
Normal file
33
ops-center/src/components/OpsShell.jsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
export function OpsShell({ activeView, children, onViewChange, views }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-shell">
|
||||||
|
<aside className="ops-sidebar" aria-label="运营中心导航">
|
||||||
|
<div className="ops-brand">
|
||||||
|
<span>OP</span>
|
||||||
|
<div>
|
||||||
|
<strong>运营中心</strong>
|
||||||
|
<small>Ops Center</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="运营中心导航" className="ops-nav">
|
||||||
|
{views.map((view) => {
|
||||||
|
const Icon = view.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-current={activeView === view.key ? "page" : undefined}
|
||||||
|
className={activeView === view.key ? "is-active" : ""}
|
||||||
|
key={view.key}
|
||||||
|
onClick={() => onViewChange(view.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
{view.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="ops-main">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
ops-center/src/components/OpsStatusBadge.jsx
Normal file
15
ops-center/src/components/OpsStatusBadge.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
|
||||||
|
// 状态徽章统一走 shared-ui 的 status-badge 体系,保证 ops-center 和主后台的状态色一致。
|
||||||
|
// tone 为空时使用中性样式(灰点),用于"默认草稿/默认水位"这类非告警状态。
|
||||||
|
export function OpsStatusBadge({ label, tone = "" }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
className={["status-badge", tone ? `status-badge--${tone}` : ""].filter(Boolean).join(" ")}
|
||||||
|
icon={<span className="status-point" />}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
ops-center/src/components/OverviewView.jsx
Normal file
124
ops-center/src/components/OverviewView.jsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
||||||
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
|
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||||
|
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { formatNumber, formatPercentFromPPM } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function OverviewView({ data }) {
|
||||||
|
const { appSummaries, poolBalances, summary } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-view">
|
||||||
|
<div className="kpi-grid ops-kpi-grid">
|
||||||
|
<KpiCard
|
||||||
|
icon={AppsOutlined}
|
||||||
|
label="运营应用"
|
||||||
|
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
|
||||||
|
value={formatNumber(summary.activeApps)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={CardGiftcardOutlined}
|
||||||
|
label="已发布奖池配置"
|
||||||
|
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
|
||||||
|
tone="info"
|
||||||
|
value={formatNumber(summary.configuredPools)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={CasinoOutlined}
|
||||||
|
label="抽奖次数"
|
||||||
|
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
|
||||||
|
tone="warning"
|
||||||
|
value={formatNumber(summary.totalDraws)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={SavingsOutlined}
|
||||||
|
label="奖池金币"
|
||||||
|
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
|
||||||
|
tone="success"
|
||||||
|
value={formatNumber(summary.poolBalanceTotal)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={poolBalanceColumns}
|
||||||
|
emptyLabel="暂无奖池余额"
|
||||||
|
items={poolBalances}
|
||||||
|
minWidth="920px"
|
||||||
|
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
|
||||||
|
title="奖池实时水位"
|
||||||
|
total={poolBalances.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={appSummaryColumns}
|
||||||
|
emptyLabel="暂无抽奖数据"
|
||||||
|
items={appSummaries}
|
||||||
|
minWidth="920px"
|
||||||
|
rowKey={(item) => item.app_code}
|
||||||
|
title="各应用抽奖汇总"
|
||||||
|
total={appSummaries.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolBalanceColumns = [
|
||||||
|
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" },
|
||||||
|
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" },
|
||||||
|
{
|
||||||
|
key: "materialized",
|
||||||
|
label: "水位状态",
|
||||||
|
// materialized=false 表示该奖池还没有真实入账流水,余额是规则推导的初始水位,不能当作可支配资金。
|
||||||
|
render: (item) =>
|
||||||
|
item.materialized === false ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
|
||||||
|
width: "minmax(132px, 0.9fr)"
|
||||||
|
},
|
||||||
|
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
|
||||||
|
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) },
|
||||||
|
{ key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor), width: "minmax(96px, 0.8fr)" },
|
||||||
|
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
|
||||||
|
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
|
||||||
|
{
|
||||||
|
key: "updated_at_ms",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) =>
|
||||||
|
item.materialized === false ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
|
||||||
|
width: "minmax(150px, 1fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const appSummaryColumns = [
|
||||||
|
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
|
||||||
|
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
|
||||||
|
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
|
||||||
|
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
|
||||||
|
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
|
||||||
|
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
|
||||||
|
{
|
||||||
|
key: "actual_rtp_ppm",
|
||||||
|
label: "实际 RTP",
|
||||||
|
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exceptions",
|
||||||
|
label: "发放异常",
|
||||||
|
// 待发放和失败合并成一列告警:正常应为 0,非 0 说明发奖链路有积压或失败,需要跟进抽奖记录。
|
||||||
|
render: (item) => {
|
||||||
|
const pending = Number(item.pending_draws || 0);
|
||||||
|
const failed = Number(item.failed_draws || 0);
|
||||||
|
if (!pending && !failed) {
|
||||||
|
return <OpsStatusBadge label="正常" tone="succeeded" />;
|
||||||
|
}
|
||||||
|
return <OpsStatusBadge label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`} tone={failed ? "danger" : "warning"} />;
|
||||||
|
},
|
||||||
|
width: "minmax(170px, 1.2fr)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function countBySource(apps = [], source) {
|
||||||
|
return apps.filter((item) => (item.source || "registry") === source).length;
|
||||||
|
}
|
||||||
158
ops-center/src/configForm.js
Normal file
158
ops-center/src/configForm.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { stageOptions } from "@/features/lucky-gift/constants.js";
|
||||||
|
import {
|
||||||
|
applyProbabilityMap,
|
||||||
|
correctRTPProbabilities,
|
||||||
|
decimal,
|
||||||
|
expectedRTPPercent,
|
||||||
|
formatDecimal,
|
||||||
|
probabilityTotal
|
||||||
|
} from "@/shared/utils/rtpProbability.js";
|
||||||
|
import { DEFAULT_POOL_ID } from "./api.js";
|
||||||
|
|
||||||
|
// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。
|
||||||
|
// 与展示层分离,弹窗组件只负责渲染和事件绑定。
|
||||||
|
|
||||||
|
export function configToForm(config = {}) {
|
||||||
|
const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent);
|
||||||
|
return {
|
||||||
|
app_code: config.app_code || config.appCode || "",
|
||||||
|
control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent),
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference),
|
||||||
|
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
||||||
|
pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent),
|
||||||
|
settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager),
|
||||||
|
stages: normalizeFormStages(config.stages, targetRTPPercent),
|
||||||
|
target_rtp_percent: targetRTPPercent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formToConfig(config, form) {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
app_code: form.app_code,
|
||||||
|
control_band_percent: numberFromForm(form.control_band_percent),
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
gift_price_reference: numberFromForm(form.gift_price_reference),
|
||||||
|
pool_id: form.pool_id,
|
||||||
|
pool_rate_percent: numberFromForm(form.pool_rate_percent),
|
||||||
|
settlement_window_wager: numberFromForm(form.settlement_window_wager),
|
||||||
|
stages: form.stages,
|
||||||
|
target_rtp_percent: numberFromForm(form.target_rtp_percent)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
|
||||||
|
// 不允许运营手工填概率绕过服务端的闭合校验。
|
||||||
|
export function correctAllStageProbabilities(form) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
stages: (form.stages || []).map((stage) => {
|
||||||
|
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
|
||||||
|
multiplier: (item) => item.multiplier
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...stage,
|
||||||
|
tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageSummary(stage, form) {
|
||||||
|
const probability = probabilityTotal(stage.tiers);
|
||||||
|
const expected = expectedRTPPercent(stage.tiers, {
|
||||||
|
multiplier: (item) => item.multiplier
|
||||||
|
});
|
||||||
|
const target = Number(form.target_rtp_percent || 0);
|
||||||
|
const band = Number(form.control_band_percent || 0);
|
||||||
|
const probabilityClosed = Math.abs(probability - 100) < 0.0001;
|
||||||
|
const rtpInBand = expected >= target - band && expected <= target + band;
|
||||||
|
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
expected,
|
||||||
|
ok: probabilityClosed && rtpInBand,
|
||||||
|
probability,
|
||||||
|
probabilityClosed,
|
||||||
|
rtpInBand,
|
||||||
|
stageKey: stage.stage,
|
||||||
|
stageLabel: fullLabel,
|
||||||
|
// 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。
|
||||||
|
status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合",
|
||||||
|
tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextStageMultiplier(tiers = []) {
|
||||||
|
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
|
||||||
|
if (!positive.length) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const max = Math.max(...positive);
|
||||||
|
if (max >= 5) {
|
||||||
|
return max * 2;
|
||||||
|
}
|
||||||
|
if (max >= 2) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
return max + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormStages(stages, targetRTPPercent = 95) {
|
||||||
|
const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage]));
|
||||||
|
return correctAllStageProbabilities({
|
||||||
|
stages: stageOptions.map(([stageKey]) => {
|
||||||
|
const source = byStage.get(stageKey);
|
||||||
|
const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey);
|
||||||
|
return {
|
||||||
|
stage: stageKey,
|
||||||
|
tiers: sourceTiers.map((tier) => ({
|
||||||
|
enabled: tier.enabled !== false,
|
||||||
|
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||||
|
multiplier: formNumber(tier.multiplier),
|
||||||
|
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
|
||||||
|
tierId: tier.tier_id || tier.tierId || ""
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
target_rtp_percent: targetRTPPercent
|
||||||
|
}).stages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStageTiers(stage) {
|
||||||
|
if (stage === "advanced") {
|
||||||
|
return [
|
||||||
|
defaultTier("advanced_none", 0, 27),
|
||||||
|
defaultTier("advanced_1x", 1, 60),
|
||||||
|
defaultTier("advanced_2x", 2, 10),
|
||||||
|
defaultTier("advanced_5x", 5, 3, { high_water_only: true })
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
defaultTier(`${stage}_none`, 0, 10),
|
||||||
|
defaultTier(`${stage}_0_5x`, 0.5, 20),
|
||||||
|
defaultTier(`${stage}_1x`, 1, 55),
|
||||||
|
defaultTier(`${stage}_2x`, 2, 15)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTier(tierID, multiplier, probabilityPercent, options = {}) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
highWaterOnly: Boolean(options.high_water_only),
|
||||||
|
multiplier,
|
||||||
|
probabilityPercent,
|
||||||
|
tierId: tierID
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? String(parsed) : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberFromForm(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
32
ops-center/src/format.js
Normal file
32
ops-center/src/format.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export function formatNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${parsed.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端 RTP 一律以 ppm(百万分比)存储避免浮点误差,展示层统一在这里换算成百分比。
|
||||||
|
export function formatPercentFromPPM(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${(parsed / 10_000).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMultiplierFromPPM(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${(parsed / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}x`;
|
||||||
|
}
|
||||||
22
ops-center/src/main.jsx
Normal file
22
ops-center/src/main.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { theme } from "@/theme.js";
|
||||||
|
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
import "@/styles/tokens.css";
|
||||||
|
import "@/styles/shared-ui.css";
|
||||||
|
import "@/styles/responsive.css";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("ops-center-root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<ToastProvider>
|
||||||
|
<CssBaseline />
|
||||||
|
<OpsCenterApp />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
407
ops-center/src/styles/index.css
Normal file
407
ops-center/src/styles/index.css
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
:root {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-page);
|
||||||
|
font-family: var(--font-system);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ops-center-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */
|
||||||
|
|
||||||
|
.ops-shell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-5) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-brand > span {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--active-contrast, #fff);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-brand strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-brand small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-nav button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
color var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-nav button:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */
|
||||||
|
.ops-nav button.is-active,
|
||||||
|
.ops-nav button.is-active:hover {
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-main {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 页面骨架 ---- */
|
||||||
|
|
||||||
|
.ops-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-view {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-status {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success-surface);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-status.is-loading {
|
||||||
|
border-color: var(--info-border);
|
||||||
|
background: var(--info-surface);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-alert {
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--warning-surface);
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 幸运礼物配置弹窗 ---- */
|
||||||
|
|
||||||
|
.ops-config-form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-title {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-title small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-metric {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
|
padding: 10px var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-metric span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-metric strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-params {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-designer {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-tabs {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
border-color var(--motion-fast) var(--ease-standard),
|
||||||
|
color var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-tab:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-tab.is-active {
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */
|
||||||
|
.ops-stage-tab.is-warn {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-tab.is-active.is-warn {
|
||||||
|
border-bottom-color: var(--warning);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-block__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-block__head > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-block__head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-table {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-head,
|
||||||
|
.ops-tier-row {
|
||||||
|
display: grid;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 520px;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(110px, 1fr)
|
||||||
|
minmax(110px, 1fr)
|
||||||
|
minmax(64px, auto)
|
||||||
|
minmax(64px, auto)
|
||||||
|
minmax(72px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-head {
|
||||||
|
min-height: 34px;
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
background: var(--bg-card-strong, #f8fafc);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-row {
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 响应式 ---- */
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.ops-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-sidebar {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-nav,
|
||||||
|
.ops-kpi-grid,
|
||||||
|
.ops-config-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-config-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ops-page {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-nav,
|
||||||
|
.ops-kpi-grid,
|
||||||
|
.ops-config-metrics,
|
||||||
|
.ops-form-grid,
|
||||||
|
.ops-tier-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-tier-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ops-nav button,
|
||||||
|
.ops-stage-tab {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -134,6 +134,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
|
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
|
||||||
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
|
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||||
|
routeNavItem("host-org-coin-seller-sub-applications", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("host-withdrawals", { icon: ReceiptLongOutlined }),
|
routeNavItem("host-withdrawals", { icon: ReceiptLongOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -191,6 +192,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
|
||||||
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
routeNavItem("operation-reports", { icon: FlagOutlined }),
|
||||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||||
|
routeNavItem("policy-template", { icon: SettingsApplicationsOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,6 +6,8 @@ export const PERMISSIONS = {
|
|||||||
userStatus: "user:status",
|
userStatus: "user:status",
|
||||||
userResetPassword: "user:reset-password",
|
userResetPassword: "user:reset-password",
|
||||||
userExport: "user:export",
|
userExport: "user:export",
|
||||||
|
jobView: "job:view",
|
||||||
|
jobCancel: "job:cancel",
|
||||||
teamView: "team:view",
|
teamView: "team:view",
|
||||||
teamCreate: "team:create",
|
teamCreate: "team:create",
|
||||||
teamUpdate: "team:update",
|
teamUpdate: "team:update",
|
||||||
@ -41,6 +43,8 @@ export const PERMISSIONS = {
|
|||||||
coinSellerUpdate: "coin-seller:update",
|
coinSellerUpdate: "coin-seller:update",
|
||||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||||
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||||
|
coinSellerSubApplicationView: "coin-seller-sub-application:view",
|
||||||
|
coinSellerSubApplicationAudit: "coin-seller-sub-application:audit",
|
||||||
hostWithdrawalView: "host-withdrawal:view",
|
hostWithdrawalView: "host-withdrawal:view",
|
||||||
financeView: "finance:view",
|
financeView: "finance:view",
|
||||||
financeApplicationCreate: "finance-application:create",
|
financeApplicationCreate: "finance-application:create",
|
||||||
@ -64,6 +68,11 @@ export const PERMISSIONS = {
|
|||||||
reportView: "report:view",
|
reportView: "report:view",
|
||||||
giftDiamondView: "gift-diamond:view",
|
giftDiamondView: "gift-diamond:view",
|
||||||
giftDiamondUpdate: "gift-diamond:update",
|
giftDiamondUpdate: "gift-diamond:update",
|
||||||
|
policyTemplateView: "policy-template:view",
|
||||||
|
policyTemplateCreate: "policy-template:create",
|
||||||
|
policyInstanceCreate: "policy-instance:create",
|
||||||
|
policyInstanceUpdate: "policy-instance:update",
|
||||||
|
policyInstancePublish: "policy-instance:publish",
|
||||||
fullServerNoticeView: "full-server-notice:view",
|
fullServerNoticeView: "full-server-notice:view",
|
||||||
fullServerNoticeSend: "full-server-notice:send",
|
fullServerNoticeSend: "full-server-notice:send",
|
||||||
paymentBillView: "payment-bill:view",
|
paymentBillView: "payment-bill:view",
|
||||||
@ -104,6 +113,8 @@ export const PERMISSIONS = {
|
|||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
appUserPassword: "app-user:password",
|
appUserPassword: "app-user:password",
|
||||||
|
appUserLevel: "app-user:level",
|
||||||
|
appUserExport: "app-user:export",
|
||||||
prettyIdView: "pretty-id:view",
|
prettyIdView: "pretty-id:view",
|
||||||
prettyIdUpdate: "pretty-id:update",
|
prettyIdUpdate: "pretty-id:update",
|
||||||
prettyIdGenerate: "pretty-id:generate",
|
prettyIdGenerate: "pretty-id:generate",
|
||||||
@ -246,6 +257,7 @@ export const MENU_CODES = {
|
|||||||
operationCoinAdjustment: "operation-coin-adjustment",
|
operationCoinAdjustment: "operation-coin-adjustment",
|
||||||
operationReports: "operation-reports",
|
operationReports: "operation-reports",
|
||||||
operationGiftDiamond: "operation-gift-diamond",
|
operationGiftDiamond: "operation-gift-diamond",
|
||||||
|
policyTemplate: "policy-template",
|
||||||
operationFullServerNotice: "operation-full-server-notice",
|
operationFullServerNotice: "operation-full-server-notice",
|
||||||
luckyGift: "lucky-gift",
|
luckyGift: "lucky-gift",
|
||||||
wheel: "wheel",
|
wheel: "wheel",
|
||||||
@ -278,6 +290,7 @@ export const MENU_CODES = {
|
|||||||
hostAgencyPolicy: "host-agency-policy",
|
hostAgencyPolicy: "host-agency-policy",
|
||||||
hostSalarySettlement: "host-salary-settlement",
|
hostSalarySettlement: "host-salary-settlement",
|
||||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||||
|
hostOrgCoinSellerSubApplications: "host-org-coin-seller-sub-applications",
|
||||||
hostWithdrawals: "host-withdrawals",
|
hostWithdrawals: "host-withdrawals",
|
||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
|
||||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
import { defaultAdminPath } from "./routeConfig";
|
import { defaultAdminPath } from "./routeConfig";
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ export function RequireAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
return <Navigate to={buildLoginRedirectPath(location)} state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
|
|||||||
import { menusRoutes } from "@/features/menus/routes.js";
|
import { menusRoutes } from "@/features/menus/routes.js";
|
||||||
import { operationsRoutes } from "@/features/operations/routes.js";
|
import { operationsRoutes } from "@/features/operations/routes.js";
|
||||||
import { paymentRoutes } from "@/features/payment/routes.js";
|
import { paymentRoutes } from "@/features/payment/routes.js";
|
||||||
|
import { policyConfigRoutes } from "@/features/policy-config/routes.js";
|
||||||
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
|
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
|
||||||
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
||||||
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
||||||
@ -65,6 +66,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...vipConfigRoutes,
|
...vipConfigRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...operationsRoutes,
|
||||||
|
...policyConfigRoutes,
|
||||||
...luckyGiftRoutes,
|
...luckyGiftRoutes,
|
||||||
...paymentRoutes,
|
...paymentRoutes,
|
||||||
...gameRoutes,
|
...gameRoutes,
|
||||||
|
|||||||
@ -1,12 +1,61 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||||
import { createSystemMessagePushFanout } from "./api";
|
import {
|
||||||
|
createH5Link,
|
||||||
|
createSystemMessagePushFanout,
|
||||||
|
deleteH5Link,
|
||||||
|
listH5Links,
|
||||||
|
updateH5Link,
|
||||||
|
updateH5Links,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("H5 config API pins every request to its explicit app scope", async () => {
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
|
const link = {
|
||||||
|
appCode: "huwaa",
|
||||||
|
key: "achievement",
|
||||||
|
label: "成就",
|
||||||
|
updatedAtMs: 1000,
|
||||||
|
url: "https://h5.example.com/achievement",
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (_url, init) => {
|
||||||
|
const method = String(init?.method || "GET");
|
||||||
|
const data = method === "GET" || (method === "PUT" && !String(_url).endsWith("/achievement"))
|
||||||
|
? { items: [link], total: 1 }
|
||||||
|
: method === "DELETE"
|
||||||
|
? { deleted: true }
|
||||||
|
: link;
|
||||||
|
return new Response(JSON.stringify({ code: 0, data }), { status: method === "POST" ? 201 : 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = { key: link.key, label: link.label, url: link.url };
|
||||||
|
await listH5Links(" Huwaa ");
|
||||||
|
await createH5Link("huwaa", payload);
|
||||||
|
await updateH5Links("huwaa", { items: [payload] });
|
||||||
|
await updateH5Link("huwaa", link.key, payload);
|
||||||
|
await deleteH5Link("huwaa", link.key);
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "POST", "PUT", "PUT", "DELETE"]);
|
||||||
|
calls.forEach(([, init]) => {
|
||||||
|
expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" });
|
||||||
|
});
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/admin/app-config/h5-links");
|
||||||
|
expect(String(calls[3][0])).toContain("/api/v1/admin/app-config/h5-links/achievement");
|
||||||
|
expect(JSON.parse(String(calls[1][1]?.body))).toEqual(payload);
|
||||||
|
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({ items: [payload] });
|
||||||
|
expect(JSON.parse(String(calls[3][1]?.body))).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
test("system message push fanout API keeps existing operations path", async () => {
|
test("system message push fanout API keeps existing operations path", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -53,47 +53,57 @@ export function createSystemMessagePushFanout(payload: SystemMessagePushPayload)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
export function listH5Links(appCode: string): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listH5Links;
|
const endpoint = API_ENDPOINTS.listH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||||
|
headers: appScopeHeaders(appCode),
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
|
export function updateH5Links(appCode: string, payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
|
||||||
const endpoint = API_ENDPOINTS.updateH5Links;
|
const endpoint = API_ENDPOINTS.updateH5Links;
|
||||||
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
|
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
|
||||||
apiEndpointPath(API_OPERATIONS.updateH5Links),
|
apiEndpointPath(API_OPERATIONS.updateH5Links),
|
||||||
{
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: appScopeHeaders(appCode),
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
export function createH5Link(appCode: string, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.createH5Link;
|
const endpoint = API_ENDPOINTS.createH5Link;
|
||||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: appScopeHeaders(appCode),
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
export function updateH5Link(appCode: string, key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateH5Link;
|
const endpoint = API_ENDPOINTS.updateH5Link;
|
||||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: appScopeHeaders(appCode),
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
export function deleteH5Link(appCode: string, key: EntityId): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteH5Link;
|
const endpoint = API_ENDPOINTS.deleteH5Link;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
||||||
|
headers: appScopeHeaders(appCode),
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appScopeHeaders(appCode: string) {
|
||||||
|
// H5 配置是强 App 隔离数据;显式固定请求头可避免用户切换 App 时全局请求上下文变化导致写入错误租户。
|
||||||
|
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreTabDto>> {
|
export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreTabDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listExploreTabs;
|
const endpoint = API_ENDPOINTS.listExploreTabs;
|
||||||
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
|
import { getSelectedAppCode } from "@/shared/api/request";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
@ -11,45 +13,66 @@ const emptyData = { items: [], total: 0 };
|
|||||||
const emptyForm = () => ({ key: "", label: "", url: "" });
|
const emptyForm = () => ({ key: "", label: "", url: "" });
|
||||||
|
|
||||||
export function useH5ConfigPage() {
|
export function useH5ConfigPage() {
|
||||||
|
const { appCode } = useAppScope();
|
||||||
const abilities = useAppConfigAbilities();
|
const abilities = useAppConfigAbilities();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [actionAppCode, setActionAppCode] = useState("");
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const queryFn = useCallback(() => listH5Links(), []);
|
const queryFn = useCallback(() => listH5Links(appCode), [appCode]);
|
||||||
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
|
enabled: Boolean(appCode),
|
||||||
errorMessage: "加载 H5 配置失败",
|
errorMessage: "加载 H5 配置失败",
|
||||||
initialData: emptyData,
|
initialData: emptyData,
|
||||||
queryKey: ["app-config", "h5-links"]
|
queryKey: ["app-config", "h5-links", appCode]
|
||||||
});
|
});
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
|
setActionAppCode(appCode);
|
||||||
setActiveAction("create");
|
setActiveAction("create");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item) => {
|
const openEdit = (item) => {
|
||||||
|
const itemAppCode = normalizeAppCode(item.appCode) || appCode;
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
|
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
|
||||||
|
// 响应中的 appCode 是该行数据的归属凭证;若页面正在切换,effect 会发现它与当前 scope 不一致并关闭弹窗。
|
||||||
|
setActionAppCode(itemAppCode);
|
||||||
setActiveAction("edit");
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEdit = () => {
|
const closeEdit = useCallback(() => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
|
setActionAppCode("");
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(emptyForm());
|
setForm(emptyForm());
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAction || sameAppCode(actionAppCode, appCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 弹窗内容来自旧 App;切换后必须丢弃,不能让同名 key 被保存到新 App。
|
||||||
|
closeEdit();
|
||||||
|
}, [activeAction, actionAppCode, appCode, closeEdit]);
|
||||||
|
|
||||||
const submitEdit = async (event) => {
|
const submitEdit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!isCurrentActionScope(actionAppCode, appCode)) {
|
||||||
|
closeEdit();
|
||||||
|
showToast("应用已切换,请重新操作", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!editingItem) {
|
if (!editingItem) {
|
||||||
const payload = parseForm(h5LinkUpdateSchema, form);
|
const payload = parseForm(h5LinkUpdateSchema, form);
|
||||||
setLoadingAction("create");
|
setLoadingAction("create");
|
||||||
try {
|
try {
|
||||||
await createH5Link(payload);
|
await createH5Link(actionAppCode, payload);
|
||||||
closeEdit();
|
closeEdit();
|
||||||
await reload();
|
await reload();
|
||||||
showToast("H5配置已新增", "success");
|
showToast("H5配置已新增", "success");
|
||||||
@ -63,7 +86,7 @@ export function useH5ConfigPage() {
|
|||||||
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
|
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
|
||||||
setLoadingAction("edit");
|
setLoadingAction("edit");
|
||||||
try {
|
try {
|
||||||
await updateH5Link(editingItem.key, payload);
|
await updateH5Link(actionAppCode, editingItem.key, payload);
|
||||||
closeEdit();
|
closeEdit();
|
||||||
await reload();
|
await reload();
|
||||||
showToast("H5配置已更新", "success");
|
showToast("H5配置已更新", "success");
|
||||||
@ -75,6 +98,7 @@ export function useH5ConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeH5Link = async (item) => {
|
const removeH5Link = async (item) => {
|
||||||
|
const targetAppCode = normalizeAppCode(item.appCode) || appCode;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
confirmText: "删除",
|
confirmText: "删除",
|
||||||
message: item.label || item.key,
|
message: item.label || item.key,
|
||||||
@ -84,9 +108,13 @@ export function useH5ConfigPage() {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isCurrentActionScope(targetAppCode, appCode)) {
|
||||||
|
showToast("应用已切换,请重新操作", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingAction(`delete:${item.key}`);
|
setLoadingAction(`delete:${item.key}`);
|
||||||
try {
|
try {
|
||||||
await deleteH5Link(item.key);
|
await deleteH5Link(targetAppCode, item.key);
|
||||||
await reload();
|
await reload();
|
||||||
showToast("H5配置已删除", "success");
|
showToast("H5配置已删除", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -114,3 +142,18 @@ export function useH5ConfigPage() {
|
|||||||
submitEdit
|
submitEdit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentActionScope(actionAppCode, appCode) {
|
||||||
|
// Header 的全局值在 AppScope state 提交前已更新,双重校验能挡住切换瞬间点击提交的竞态。
|
||||||
|
return Boolean(normalizeAppCode(actionAppCode))
|
||||||
|
&& sameAppCode(actionAppCode, appCode)
|
||||||
|
&& sameAppCode(actionAppCode, getSelectedAppCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAppCode(left, right) {
|
||||||
|
return normalizeAppCode(left) === normalizeAppCode(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppCode(value) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|||||||
179
src/features/app-config/hooks/useH5ConfigPage.test.jsx
Normal file
179
src/features/app-config/hooks/useH5ConfigPage.test.jsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { setSelectedAppCode } from "@/shared/api/request";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
appCode: "lalu",
|
||||||
|
confirm: vi.fn(),
|
||||||
|
createH5Link: vi.fn(),
|
||||||
|
deleteH5Link: vi.fn(),
|
||||||
|
listH5Links: vi.fn(),
|
||||||
|
queryFn: null,
|
||||||
|
queryOptions: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
updateH5Link: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
|
||||||
|
useAppScope: () => ({ appCode: mocks.appCode })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/app-config/api", () => ({
|
||||||
|
createH5Link: mocks.createH5Link,
|
||||||
|
deleteH5Link: mocks.deleteH5Link,
|
||||||
|
listH5Links: mocks.listH5Links,
|
||||||
|
updateH5Link: mocks.updateH5Link
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/app-config/permissions.js", () => ({
|
||||||
|
useAppConfigAbilities: () => ({ canUpdate: true, canView: true })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||||
|
useAdminQuery: (queryFn, options) => {
|
||||||
|
mocks.queryFn = queryFn;
|
||||||
|
mocks.queryOptions = options;
|
||||||
|
return {
|
||||||
|
data: { items: [], total: 0 },
|
||||||
|
error: "",
|
||||||
|
loading: false,
|
||||||
|
reload: mocks.reload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
|
||||||
|
useConfirm: () => mocks.confirm
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: () => ({ showToast: mocks.showToast })
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useH5ConfigPage } from "./useH5ConfigPage.js";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.appCode = "lalu";
|
||||||
|
mocks.confirm.mockResolvedValue(true);
|
||||||
|
mocks.createH5Link.mockResolvedValue({});
|
||||||
|
mocks.deleteH5Link.mockResolvedValue({ deleted: true });
|
||||||
|
mocks.listH5Links.mockResolvedValue({ items: [], total: 0 });
|
||||||
|
mocks.reload.mockResolvedValue({ items: [], total: 0 });
|
||||||
|
mocks.updateH5Link.mockResolvedValue({});
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses appCode in the H5 query key and read request", async () => {
|
||||||
|
const { rerender } = renderHook(() => useH5ConfigPage());
|
||||||
|
|
||||||
|
expect(mocks.queryOptions.queryKey).toEqual(["app-config", "h5-links", "lalu"]);
|
||||||
|
await mocks.queryFn();
|
||||||
|
expect(mocks.listH5Links).toHaveBeenLastCalledWith("lalu");
|
||||||
|
|
||||||
|
mocks.appCode = "huwaa";
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(mocks.queryOptions.queryKey).toEqual(["app-config", "h5-links", "huwaa"]);
|
||||||
|
await mocks.queryFn();
|
||||||
|
expect(mocks.listH5Links).toHaveBeenLastCalledWith("huwaa");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes an edit dialog when the selected App changes", () => {
|
||||||
|
const item = {
|
||||||
|
appCode: "lalu",
|
||||||
|
key: "achievement",
|
||||||
|
label: "成就",
|
||||||
|
updatedAtMs: 1000,
|
||||||
|
url: "https://h5.example.com/achievement"
|
||||||
|
};
|
||||||
|
const { result, rerender } = renderHook(() => useH5ConfigPage());
|
||||||
|
|
||||||
|
act(() => result.current.openEdit(item));
|
||||||
|
expect(result.current.activeAction).toBe("edit");
|
||||||
|
|
||||||
|
mocks.appCode = "huwaa";
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(result.current.activeAction).toBe("");
|
||||||
|
expect(result.current.editingItem).toBeNull();
|
||||||
|
expect(result.current.form).toEqual({ key: "", label: "", url: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not submit stale form state after an App switch", async () => {
|
||||||
|
const { result, rerender } = renderHook(() => useH5ConfigPage());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEdit({
|
||||||
|
appCode: "lalu",
|
||||||
|
key: "achievement",
|
||||||
|
label: "成就",
|
||||||
|
updatedAtMs: 1000,
|
||||||
|
url: "https://h5.example.com/achievement"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.appCode = "huwaa";
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
rerender();
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitEdit({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.createH5Link).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.updateH5Link).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("应用已切换,请重新操作", "warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pins an update mutation to the App that owns the edited row", async () => {
|
||||||
|
const { result } = renderHook(() => useH5ConfigPage());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEdit({
|
||||||
|
appCode: "lalu",
|
||||||
|
key: "achievement",
|
||||||
|
label: "成就",
|
||||||
|
updatedAtMs: 1000,
|
||||||
|
url: "https://h5.example.com/achievement"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitEdit({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateH5Link).toHaveBeenCalledWith("lalu", "achievement", {
|
||||||
|
key: "achievement",
|
||||||
|
label: "成就",
|
||||||
|
url: "https://h5.example.com/achievement"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts delete when App changes while confirmation is open", async () => {
|
||||||
|
let resolveConfirm;
|
||||||
|
mocks.confirm.mockReturnValue(new Promise((resolve) => {
|
||||||
|
resolveConfirm = resolve;
|
||||||
|
}));
|
||||||
|
const { result, rerender } = renderHook(() => useH5ConfigPage());
|
||||||
|
let pendingDelete;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
pendingDelete = result.current.removeH5Link({ key: "achievement", label: "成就" });
|
||||||
|
});
|
||||||
|
mocks.appCode = "huwaa";
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
rerender();
|
||||||
|
await act(async () => {
|
||||||
|
resolveConfirm(true);
|
||||||
|
await pendingDelete;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.deleteH5Link).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("应用已切换,请重新操作", "warning");
|
||||||
|
});
|
||||||
@ -1,6 +1,15 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
|
import {
|
||||||
|
adjustAppUserLevels,
|
||||||
|
banAppUser,
|
||||||
|
createAppUserExport,
|
||||||
|
getAppUser,
|
||||||
|
listAppUsers,
|
||||||
|
listPrettyDisplayIDs,
|
||||||
|
recyclePrettyDisplayID,
|
||||||
|
unbanAppUser,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
@ -351,3 +360,147 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => {
|
|||||||
resourceCode: "frame_gold",
|
resourceCode: "frame_gold",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("app user projection normalizes levels roles ban and successful login fields", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
age: 26,
|
||||||
|
ban: {
|
||||||
|
active: true,
|
||||||
|
expires_at_ms: 1790000000000,
|
||||||
|
id: "ban-1",
|
||||||
|
permanent: false,
|
||||||
|
reason: "risk",
|
||||||
|
source: "admin",
|
||||||
|
},
|
||||||
|
birth: "2000-02-29",
|
||||||
|
cumulative_recharge_usd_minor: 12345,
|
||||||
|
last_login_at_ms: 1780000000000,
|
||||||
|
last_operated_at_ms: 1781000000000,
|
||||||
|
last_operator: { action: "ban", admin_id: "7", name: "运营" },
|
||||||
|
levels: {
|
||||||
|
wealth: {
|
||||||
|
display_level: 20,
|
||||||
|
expires_at_ms: 1790000000000,
|
||||||
|
real_level: 8,
|
||||||
|
temporary_level_id: "temporary-1",
|
||||||
|
temporary_target_level: 20,
|
||||||
|
track: "wealth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register_device: "iPhone17,2",
|
||||||
|
roles: ["主播", "BD"],
|
||||||
|
user_id: "10001",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getAppUser("10001");
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
age: 26,
|
||||||
|
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
|
||||||
|
birth: "2000-02-29",
|
||||||
|
cumulativeRechargeUsdMinor: 12345,
|
||||||
|
lastLoginAtMs: 1780000000000,
|
||||||
|
lastOperatedAtMs: 1781000000000,
|
||||||
|
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
||||||
|
levels: {
|
||||||
|
wealth: expect.objectContaining({
|
||||||
|
displayLevel: 20,
|
||||||
|
realLevel: 8,
|
||||||
|
temporaryLevelId: "temporary-1",
|
||||||
|
temporaryTargetLevel: 20,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
registerDevice: "iPhone17,2",
|
||||||
|
roles: ["主播", "BD"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("temporary level adjustment sends wealth and charm in one request", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { user_id: "10001" } }))),
|
||||||
|
);
|
||||||
|
const payload = {
|
||||||
|
adjustments: [
|
||||||
|
{ duration_days: 7, level: 20, track: "wealth" as const },
|
||||||
|
{ duration_days: 3, level: 18, track: "charm" as const },
|
||||||
|
],
|
||||||
|
reason: "campaign",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adjustAppUserLevels("10001", payload);
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/app/users/10001/levels");
|
||||||
|
expect(init?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual(payload);
|
||||||
|
expect(result.userId).toBe("10001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ban and unban normalize the status mutation user wrapper", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
ban: { active: true, expires_at_ms: 1790000000000, permanent: false },
|
||||||
|
status: "banned",
|
||||||
|
user_id: "10001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ code: 0, data: { user: { ban: { active: false }, status: "active", user_id: "10001" } } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const banned = await banAppUser("10001", { expires_at_ms: 1790000000000, reason: "risk" });
|
||||||
|
const unbanned = await unbanAppUser("10001", { reason: "cleared" });
|
||||||
|
|
||||||
|
expect(banned).toMatchObject({ ban: { active: true, expiresAtMs: 1790000000000 }, status: "banned" });
|
||||||
|
expect(unbanned).toMatchObject({ ban: { active: false }, status: "active" });
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
expires_at_ms: 1790000000000,
|
||||||
|
reason: "risk",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ reason: "cleared" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app user export creates a task from the current filter snapshot", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ code: 0, data: { jobId: 77, snapshotAtMs: 1781000000000, status: "pending" } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await createAppUserExport({ country: "AE", status: "banned" });
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/exports/app-users?");
|
||||||
|
expect(String(url)).toContain("country=AE");
|
||||||
|
expect(String(url)).toContain("status=banned");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(result).toEqual({ jobId: 77, snapshotAtMs: 1781000000000, status: "pending" });
|
||||||
|
});
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type {
|
||||||
|
AdminJobDto,
|
||||||
ApiPage,
|
ApiPage,
|
||||||
|
AppUserBanPayload,
|
||||||
AppUserBanRecordDto,
|
AppUserBanRecordDto,
|
||||||
|
AppUserExportJobDto,
|
||||||
|
AppUserLevelAdjustmentPayload,
|
||||||
AppUserLoginLogDto,
|
AppUserLoginLogDto,
|
||||||
AppUserDto,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
|
AppUserUnbanPayload,
|
||||||
AppUserUpdatePayload,
|
AppUserUpdatePayload,
|
||||||
AdminGrantPrettyDisplayIDResultDto,
|
AdminGrantPrettyDisplayIDResultDto,
|
||||||
EntityId,
|
EntityId,
|
||||||
@ -76,20 +81,54 @@ export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayl
|
|||||||
return normalizeAppUser(data);
|
return normalizeAppUser(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
|
export async function adjustAppUserLevels(
|
||||||
const endpoint = API_ENDPOINTS.appBanUser;
|
userId: EntityId,
|
||||||
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
|
payload: AppUserLevelAdjustmentPayload,
|
||||||
method: endpoint.method,
|
): Promise<AppUserDto> {
|
||||||
});
|
const endpoint = API_ENDPOINTS.appAdjustUserLevels;
|
||||||
|
const data = await apiRequest<RawAppUser, AppUserLevelAdjustmentPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }),
|
||||||
|
{ body: payload, method: endpoint.method },
|
||||||
|
);
|
||||||
return normalizeAppUser(data);
|
return normalizeAppUser(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
|
export async function banAppUser(userId: EntityId, payload: AppUserBanPayload): Promise<AppUserDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.appBanUser;
|
||||||
|
const data = await apiRequest<RawAppUserStatusMutation, AppUserBanPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }),
|
||||||
|
{ body: payload, method: endpoint.method },
|
||||||
|
);
|
||||||
|
// 新接口返回 SetUserStatusResult;兼容旧环境直接返回 AppUser,避免滚动发布期间列表补丁丢失 userId。
|
||||||
|
return normalizeAppUser(data.user ?? data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanAppUser(userId: EntityId, payload: AppUserUnbanPayload = {}): Promise<AppUserDto> {
|
||||||
const endpoint = API_ENDPOINTS.appUnbanUser;
|
const endpoint = API_ENDPOINTS.appUnbanUser;
|
||||||
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
|
const data = await apiRequest<RawAppUserStatusMutation, AppUserUnbanPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }),
|
||||||
|
{ body: payload, method: endpoint.method },
|
||||||
|
);
|
||||||
|
return normalizeAppUser(data.user ?? data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppUserExport(query: PageQuery = {}): Promise<AppUserExportJobDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.appExportUsers;
|
||||||
|
return apiRequest<AppUserExportJobDto>(apiEndpointPath(API_OPERATIONS.appExportUsers), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppUserExportJob(jobId: EntityId): Promise<AdminJobDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getJob;
|
||||||
|
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
return normalizeAppUser(data);
|
}
|
||||||
|
|
||||||
|
export function downloadAppUserExport(jobId: EntityId): Promise<Response> {
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAppUserPassword(
|
export function setAppUserPassword(
|
||||||
@ -213,8 +252,11 @@ export async function setPrettyDisplayIDStatus(
|
|||||||
|
|
||||||
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
||||||
interface RawAppUser {
|
interface RawAppUser {
|
||||||
|
age?: number | null;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
balances?: RawAppUserAssetBalance[];
|
balances?: RawAppUserAssetBalance[];
|
||||||
|
ban?: RawAppUserBan;
|
||||||
|
birth?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
country_display_name?: string;
|
country_display_name?: string;
|
||||||
@ -233,6 +275,13 @@ interface RawAppUser {
|
|||||||
gender?: string;
|
gender?: string;
|
||||||
last_active_at_ms?: number;
|
last_active_at_ms?: number;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
|
last_login_at_ms?: number;
|
||||||
|
lastLoginAtMs?: number;
|
||||||
|
last_operated_at_ms?: number;
|
||||||
|
lastOperatedAtMs?: number;
|
||||||
|
last_operator?: RawAppUserOperator;
|
||||||
|
lastOperator?: RawAppUserOperator;
|
||||||
|
levels?: RawAppUserLevels;
|
||||||
pretty_display_user_id?: string;
|
pretty_display_user_id?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
pretty_id?: string;
|
pretty_id?: string;
|
||||||
@ -241,7 +290,10 @@ interface RawAppUser {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
region_name?: string;
|
region_name?: string;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
register_device?: string;
|
||||||
|
registerDevice?: string;
|
||||||
resources?: RawAppUserResource[];
|
resources?: RawAppUserResource[];
|
||||||
|
roles?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updated_at_ms?: number;
|
updated_at_ms?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -249,6 +301,55 @@ interface RawAppUser {
|
|||||||
userId?: EntityId;
|
userId?: EntityId;
|
||||||
username?: string;
|
username?: string;
|
||||||
vip?: RawAppUserVIP;
|
vip?: RawAppUserVIP;
|
||||||
|
cumulative_recharge_usd_minor?: number;
|
||||||
|
cumulativeRechargeUsdMinor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserStatusMutation extends RawAppUser {
|
||||||
|
user?: RawAppUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserLevel {
|
||||||
|
display_level?: number;
|
||||||
|
displayLevel?: number;
|
||||||
|
display_value?: number;
|
||||||
|
displayValue?: number;
|
||||||
|
expires_at_ms?: number;
|
||||||
|
expiresAtMs?: number;
|
||||||
|
real_level?: number;
|
||||||
|
realLevel?: number;
|
||||||
|
real_total_value?: number;
|
||||||
|
realTotalValue?: number;
|
||||||
|
started_at_ms?: number;
|
||||||
|
startedAtMs?: number;
|
||||||
|
temporary_level_id?: string;
|
||||||
|
temporaryLevelId?: string;
|
||||||
|
temporary_target_level?: number;
|
||||||
|
temporaryTargetLevel?: number;
|
||||||
|
track?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserLevels {
|
||||||
|
charm?: RawAppUserLevel;
|
||||||
|
game?: RawAppUserLevel;
|
||||||
|
wealth?: RawAppUserLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserBan {
|
||||||
|
active?: boolean;
|
||||||
|
expires_at_ms?: number;
|
||||||
|
expiresAtMs?: number;
|
||||||
|
id?: string;
|
||||||
|
permanent?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserOperator {
|
||||||
|
action?: string;
|
||||||
|
admin_id?: EntityId;
|
||||||
|
adminId?: EntityId;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawAppUserBrief = RawAppUser;
|
type RawAppUserBrief = RawAppUser;
|
||||||
@ -503,8 +604,11 @@ function normalizePage<TRaw, TItem>(
|
|||||||
|
|
||||||
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
||||||
return {
|
return {
|
||||||
|
age: optionalNumberValue(item.age),
|
||||||
avatar: stringValue(item.avatar),
|
avatar: stringValue(item.avatar),
|
||||||
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
||||||
|
ban: normalizeAppUserBan(item.ban),
|
||||||
|
birth: stringValue(item.birth),
|
||||||
coin: numberValue(item.coin),
|
coin: numberValue(item.coin),
|
||||||
country: stringValue(item.country),
|
country: stringValue(item.country),
|
||||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
||||||
@ -516,16 +620,69 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
|||||||
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
|
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
|
||||||
gender: stringValue(item.gender),
|
gender: stringValue(item.gender),
|
||||||
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
||||||
|
lastLoginAtMs: numberValue(item.lastLoginAtMs ?? item.last_login_at_ms),
|
||||||
|
lastOperatedAtMs: numberValue(item.lastOperatedAtMs ?? item.last_operated_at_ms),
|
||||||
|
lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator),
|
||||||
|
levels: normalizeAppUserLevels(item.levels),
|
||||||
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
||||||
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
regionName: stringValue(item.regionName ?? item.region_name),
|
regionName: stringValue(item.regionName ?? item.region_name),
|
||||||
|
registerDevice: stringValue(item.registerDevice ?? item.register_device),
|
||||||
resources: (item.resources || []).map(normalizeAppUserResource),
|
resources: (item.resources || []).map(normalizeAppUserResource),
|
||||||
|
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
username: stringValue(item.username),
|
username: stringValue(item.username),
|
||||||
vip: normalizeAppUserVIP(item.vip),
|
vip: normalizeAppUserVIP(item.vip),
|
||||||
|
cumulativeRechargeUsdMinor: numberValue(
|
||||||
|
item.cumulativeRechargeUsdMinor ?? item.cumulative_recharge_usd_minor,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserLevels(item: RawAppUserLevels = {}) {
|
||||||
|
return {
|
||||||
|
charm: normalizeAppUserLevel(item.charm, "charm"),
|
||||||
|
game: normalizeAppUserLevel(item.game, "game"),
|
||||||
|
wealth: normalizeAppUserLevel(item.wealth, "wealth"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserLevel(item: RawAppUserLevel = {}, fallbackTrack: string) {
|
||||||
|
return {
|
||||||
|
displayLevel: numberValue(item.displayLevel ?? item.display_level),
|
||||||
|
displayValue: numberValue(item.displayValue ?? item.display_value),
|
||||||
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
|
realLevel: numberValue(item.realLevel ?? item.real_level),
|
||||||
|
realTotalValue: numberValue(item.realTotalValue ?? item.real_total_value),
|
||||||
|
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
|
||||||
|
temporaryLevelId: stringValue(item.temporaryLevelId ?? item.temporary_level_id),
|
||||||
|
temporaryTargetLevel: numberValue(item.temporaryTargetLevel ?? item.temporary_target_level),
|
||||||
|
track: stringValue(item.track || fallbackTrack),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserBan(item: RawAppUserBan = {}) {
|
||||||
|
return {
|
||||||
|
active: Boolean(item.active),
|
||||||
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
|
id: stringValue(item.id),
|
||||||
|
permanent: Boolean(item.permanent),
|
||||||
|
reason: stringValue(item.reason),
|
||||||
|
source: stringValue(item.source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserOperator(item?: RawAppUserOperator) {
|
||||||
|
if (!item) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action: stringValue(item.action),
|
||||||
|
adminId: stringValue(item.adminId ?? item.admin_id),
|
||||||
|
name: stringValue(item.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,6 +856,14 @@ function numberValue(value: unknown): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalNumberValue(value: unknown): number | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// 靓号明细包含池号和后台发放两类来源,pool 可能为空,归一化后页面只判断 source/status 即可。
|
// 靓号明细包含池号和后台发放两类来源,pool 可能为空,归一化后页面只判断 source/status 即可。
|
||||||
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
|
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
|
||||||
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";
|
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";
|
||||||
|
|||||||
@ -413,6 +413,28 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.levelPrimary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rolesValue {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 650;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banCountdown {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.detailHero {
|
.detailHero {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -669,6 +691,20 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.levelForm {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTrackForm {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.prettyTabsBar {
|
.prettyTabsBar {
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
|
|||||||
@ -6,6 +6,14 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { getAppUser } from "@/features/app-users/api";
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||||||
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
|
import {
|
||||||
|
BanCountdown,
|
||||||
|
GenderValue,
|
||||||
|
LevelValue,
|
||||||
|
OperatorValue,
|
||||||
|
RolesValue,
|
||||||
|
formatUsdMinor,
|
||||||
|
} from "@/features/app-users/components/AppUserValues.jsx";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
export function AppUserDetailProvider({ children }) {
|
export function AppUserDetailProvider({ children }) {
|
||||||
@ -89,22 +97,43 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||||||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||||||
<DetailItem label="昵称" value={user.username} />
|
<DetailItem label="昵称" value={user.username} />
|
||||||
<DetailItem label="性别" value={formatGender(user.gender)} />
|
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
||||||
|
<DetailItem label="生日" value={user.birth} />
|
||||||
|
<DetailItem label="年龄" value={formatAge(user.age)} />
|
||||||
|
<DetailItem label="注册设备" value={user.registerDevice} />
|
||||||
|
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>等级信息</h3>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<DetailItem label="富豪等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
||||||
|
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
||||||
|
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
||||||
|
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||||||
|
<DetailItem label="VIP 到期时间" value={formatVipExpire(user.vip)} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className={styles.detailSection}>
|
<section className={styles.detailSection}>
|
||||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||||||
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
||||||
|
<DetailItem label="封禁原因" value={user.ban?.reason} />
|
||||||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||||
|
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||||
<DetailItem label="国家" value={formatCountry(user)} />
|
<DetailItem label="国家" value={formatCountry(user)} />
|
||||||
<DetailItem
|
<DetailItem
|
||||||
label="区域"
|
label="区域"
|
||||||
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
||||||
/>
|
/>
|
||||||
|
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
||||||
|
<DetailItem
|
||||||
|
label="最新操作"
|
||||||
|
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
||||||
|
/>
|
||||||
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
||||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||||
</div>
|
</div>
|
||||||
@ -248,25 +277,26 @@ function formatCountry(user) {
|
|||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGender(gender) {
|
|
||||||
const value = String(gender || "").toLowerCase();
|
|
||||||
if (value === "male" || value === "m" || value === "男") {
|
|
||||||
return "男";
|
|
||||||
}
|
|
||||||
if (value === "female" || value === "f" || value === "女") {
|
|
||||||
return "女";
|
|
||||||
}
|
|
||||||
if (value === "non_binary") {
|
|
||||||
return "非二元";
|
|
||||||
}
|
|
||||||
return gender || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAge(value) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const age = Number(value);
|
||||||
|
return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVipExpire(vip) {
|
||||||
|
if (Number(vip?.level || 0) <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return formatExpireTime(vip?.expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
function formatAssetType(value) {
|
function formatAssetType(value) {
|
||||||
const normalized = String(value || "").toUpperCase();
|
const normalized = String(value || "").toUpperCase();
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
|
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
|
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||||
|
|
||||||
|
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => {
|
||||||
|
vi.mocked(getAppUser).mockResolvedValue({
|
||||||
|
age: 26,
|
||||||
|
avatar: "",
|
||||||
|
ban: { active: true, permanent: true, reason: "risk" },
|
||||||
|
birth: "2000-02-29",
|
||||||
|
coin: 1200,
|
||||||
|
countryDisplayName: "阿联酋",
|
||||||
|
createdAtMs: 1780000000000,
|
||||||
|
cumulativeRechargeUsdMinor: 12345,
|
||||||
|
defaultDisplayUserId: "163337",
|
||||||
|
diamond: 88,
|
||||||
|
displayUserId: "163337",
|
||||||
|
gender: "female",
|
||||||
|
lastLoginAtMs: 1781000000000,
|
||||||
|
lastOperatedAtMs: 1782000000000,
|
||||||
|
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
||||||
|
levels: {
|
||||||
|
charm: { displayLevel: 18, realLevel: 7, temporaryTargetLevel: 18, expiresAtMs: 1790000000000 },
|
||||||
|
game: { displayLevel: 9, realLevel: 9 },
|
||||||
|
wealth: { displayLevel: 20, realLevel: 8, temporaryTargetLevel: 20, expiresAtMs: 1790000000000 },
|
||||||
|
},
|
||||||
|
prettyDisplayUserId: "VIP888",
|
||||||
|
prettyId: "pretty-888",
|
||||||
|
regionName: "中东区",
|
||||||
|
registerDevice: "iPhone17,2",
|
||||||
|
roles: ["主播", "BD"],
|
||||||
|
status: "banned",
|
||||||
|
updatedAtMs: 1782000000000,
|
||||||
|
userId: "10001",
|
||||||
|
username: "tester",
|
||||||
|
vip: { active: true, expiresAtMs: 1795000000000, level: 3, name: "黄金会员" },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserDetailProvider>
|
||||||
|
<DetailTrigger />
|
||||||
|
</AppUserDetailProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("VIP888")).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("注册设备")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("iPhone17,2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("主播、BD")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("累计充值")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/123\.45/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("永久")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/真实 Lv8/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("VIP 3 · 黄金会员")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
function DetailTrigger() {
|
||||||
|
const { openAppUserDetail } = useAppUserDetail();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => openAppUserDetail({ userId: "10001", username: "tester" })}>
|
||||||
|
打开详情
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/features/app-users/components/AppUserValues.jsx
Normal file
152
src/features/app-users/components/AppUserValues.jsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
admin: "管理员",
|
||||||
|
bd: "BD",
|
||||||
|
bd_leader: "BD Leader",
|
||||||
|
bdleader: "BD Leader",
|
||||||
|
coin_seller: "币商",
|
||||||
|
guild_leader: "公会长",
|
||||||
|
host: "主播",
|
||||||
|
manager: "经理",
|
||||||
|
normal: "普通用户",
|
||||||
|
ordinary: "普通用户",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenderValue({ gender }) {
|
||||||
|
return formatGender(gender);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LevelValue({ level, showReal = false }) {
|
||||||
|
if (!level) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const displayLevel = numericLevel(level.displayLevel ?? level.realLevel);
|
||||||
|
const realLevel = numericLevel(level.realLevel);
|
||||||
|
const temporary = Number(level.expiresAtMs || 0) > 0 && Number(level.temporaryTargetLevel || 0) > 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.levelPrimary}>Lv{displayLevel}</span>
|
||||||
|
{showReal || temporary ? (
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{showReal ? `真实 Lv${realLevel}` : ""}
|
||||||
|
{showReal && temporary ? " · " : ""}
|
||||||
|
{temporary ? `临时至 ${formatMillis(level.expiresAtMs)}` : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolesValue({ roles }) {
|
||||||
|
const labels = normalizeRoles(roles);
|
||||||
|
return <span className={styles.rolesValue}>{labels.join("、")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperatorValue({ operatedAtMs, operator }) {
|
||||||
|
if (!operator?.name && !operator?.adminId && !operatedAtMs) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const name = operator?.name || (operator?.adminId ? `管理员 ${operator.adminId}` : "-");
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{name}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{[operator?.action, formatMillis(operatedAtMs)].filter((value) => value && value !== "-").join(" · ") || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BanCountdown({ ban, status }) {
|
||||||
|
const [now, setNow] = useState(0);
|
||||||
|
const banned = status === "banned" || status === "disabled" || Boolean(ban?.active);
|
||||||
|
const expiresAtMs = Number(ban?.expiresAtMs || 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!banned || ban?.permanent || expiresAtMs <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const startupTimer = window.setTimeout(() => setNow(Date.now()), 0);
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(startupTimer);
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [ban?.permanent, banned, expiresAtMs]);
|
||||||
|
|
||||||
|
const label = useMemo(() => {
|
||||||
|
if (!banned) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 兼容迁移前只有 banned/disabled 状态、没有事实记录的用户:按永久展示,绝不因缺失到期时间误判为可自动解封。
|
||||||
|
if (ban?.permanent || expiresAtMs <= 0) {
|
||||||
|
return "永久";
|
||||||
|
}
|
||||||
|
if (now <= 0) {
|
||||||
|
return `至 ${formatMillis(expiresAtMs)}`;
|
||||||
|
}
|
||||||
|
const remainingMs = expiresAtMs - now;
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
return "等待自动解封";
|
||||||
|
}
|
||||||
|
return formatDuration(remainingMs);
|
||||||
|
}, [ban?.permanent, banned, expiresAtMs, now]);
|
||||||
|
|
||||||
|
return <span className={banned ? styles.banCountdown : styles.meta}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGender(gender) {
|
||||||
|
const value = String(gender || "").toLowerCase();
|
||||||
|
if (value === "male" || value === "m" || value === "男") {
|
||||||
|
return "男";
|
||||||
|
}
|
||||||
|
if (value === "female" || value === "f" || value === "女") {
|
||||||
|
return "女";
|
||||||
|
}
|
||||||
|
if (value === "non_binary") {
|
||||||
|
return "非二元";
|
||||||
|
}
|
||||||
|
return gender || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsdMinor(value) {
|
||||||
|
const amount = Number(value);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("zh-CN", { currency: "USD", style: "currency" }).format(amount / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRoles(roles) {
|
||||||
|
const values = Array.isArray(roles) ? roles.filter(Boolean) : [];
|
||||||
|
if (!values.length) {
|
||||||
|
return ["普通用户"];
|
||||||
|
}
|
||||||
|
return Array.from(new Set(values.map((role) => roleLabels[String(role).toLowerCase()] || String(role))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericLevel(value) {
|
||||||
|
const level = Number(value || 0);
|
||||||
|
return Number.isFinite(level) ? Math.max(0, Math.trunc(level)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(milliseconds) {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}天 ${hours}小时`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时 ${minutes}分`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分 ${seconds}秒`;
|
||||||
|
}
|
||||||
|
return `${seconds}秒`;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { downloadResponse } from "@/shared/api/download";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
@ -6,14 +7,24 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
|||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
||||||
import {
|
import {
|
||||||
|
adjustAppUserLevels,
|
||||||
banAppUser,
|
banAppUser,
|
||||||
|
createAppUserExport,
|
||||||
|
downloadAppUserExport,
|
||||||
|
getAppUserExportJob,
|
||||||
listAppUsers,
|
listAppUsers,
|
||||||
setAppUserPassword,
|
setAppUserPassword,
|
||||||
unbanAppUser,
|
unbanAppUser,
|
||||||
updateAppUser,
|
updateAppUser,
|
||||||
} from "@/features/app-users/api";
|
} from "@/features/app-users/api";
|
||||||
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
||||||
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
|
import {
|
||||||
|
appUserBanSchema,
|
||||||
|
appUserCountrySchema,
|
||||||
|
appUserLevelAdjustmentSchema,
|
||||||
|
appUserPasswordSchema,
|
||||||
|
appUserUpdateSchema,
|
||||||
|
} from "@/features/app-users/schema";
|
||||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -21,6 +32,7 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
|||||||
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
|
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
|
||||||
const emptyCountryForm = () => ({ country: "" });
|
const emptyCountryForm = () => ({ country: "" });
|
||||||
const emptyPasswordForm = () => ({ password: "" });
|
const emptyPasswordForm = () => ({ password: "" });
|
||||||
|
const emptyBanForm = () => ({ expiresAtLocal: futureDateTimeLocal(7), mode: "permanent", reason: "" });
|
||||||
|
|
||||||
export function useAppUsersPage() {
|
export function useAppUsersPage() {
|
||||||
const abilities = useAppUserAbilities();
|
const abilities = useAppUserAbilities();
|
||||||
@ -41,6 +53,8 @@ export function useAppUsersPage() {
|
|||||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||||
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||||
|
const [levelForm, setLevelForm] = useState(() => levelFormFromUser(null));
|
||||||
|
const [banForm, setBanForm] = useState(emptyBanForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
||||||
const [patchedUsers, setPatchedUsers] = useState({});
|
const [patchedUsers, setPatchedUsers] = useState({});
|
||||||
@ -141,6 +155,8 @@ export function useAppUsersPage() {
|
|||||||
setRegionId("");
|
setRegionId("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setTimeRange({ endMs: "", startMs: "" });
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setSortBy("created_at");
|
||||||
|
setSortDirection("desc");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -151,6 +167,7 @@ export function useAppUsersPage() {
|
|||||||
gender: user.gender || "",
|
gender: user.gender || "",
|
||||||
username: user.username || "",
|
username: user.username || "",
|
||||||
});
|
});
|
||||||
|
setLevelForm(levelFormFromUser(user));
|
||||||
setActiveAction("edit");
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -183,9 +200,30 @@ export function useAppUsersPage() {
|
|||||||
}
|
}
|
||||||
await runAction("edit", "用户已更新", async () => {
|
await runAction("edit", "用户已更新", async () => {
|
||||||
const payload = parseForm(appUserUpdateSchema, editForm);
|
const payload = parseForm(appUserUpdateSchema, editForm);
|
||||||
await updateAppUser(activeUser.userId, payload);
|
const result = await updateAppUser(activeUser.userId, payload);
|
||||||
closeAction();
|
patchUsers([result]);
|
||||||
await reload();
|
setActiveUser((current) => (current ? { ...current, ...result } : result));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLevels = async () => {
|
||||||
|
if (!activeUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("levels", "等级调整已提交", async () => {
|
||||||
|
const form = parseForm(appUserLevelAdjustmentSchema, levelForm);
|
||||||
|
// 两条轨道只发一个 adjustments 数组,保证跨轨道由 activity-service 同事务提交或整体失败。
|
||||||
|
const adjustments = ["wealth", "charm"]
|
||||||
|
.filter((track) => form[track].enabled)
|
||||||
|
.map((track) => ({
|
||||||
|
duration_days: Number(form[track].durationDays),
|
||||||
|
level: Number(form[track].level),
|
||||||
|
track,
|
||||||
|
}));
|
||||||
|
const result = await adjustAppUserLevels(activeUser.userId, { adjustments, reason: form.reason });
|
||||||
|
patchUsers([result]);
|
||||||
|
setActiveUser((current) => (current ? { ...current, ...result } : result));
|
||||||
|
setLevelForm(levelFormFromUser(result));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -216,13 +254,45 @@ export function useAppUsersPage() {
|
|||||||
|
|
||||||
const toggleBan = async (user) => {
|
const toggleBan = async (user) => {
|
||||||
const banned = isBanned(user);
|
const banned = isBanned(user);
|
||||||
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
|
if (!banned) {
|
||||||
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
|
setActiveUser(user);
|
||||||
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
|
setBanForm(emptyBanForm());
|
||||||
|
setActiveAction("ban");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await confirm({
|
||||||
|
confirmText: "解封",
|
||||||
|
message: "仅当该用户不存在其他有效管理员或经理封禁时,账号状态才会恢复。",
|
||||||
|
title: "解封用户",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`status-${user.userId}`, "用户已解封", async () => {
|
||||||
|
const result = await unbanAppUser(user.userId, { reason: "admin_manual_unban" });
|
||||||
|
patchUsers([mergeUserStatus(user, result, "active")]);
|
||||||
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
|
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitBan = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`status-${activeUser.userId}`, "用户已封禁", async () => {
|
||||||
|
const form = parseForm(appUserBanSchema, banForm);
|
||||||
|
const expiresAtMs = form.mode === "permanent" ? 0 : new Date(form.expiresAtLocal).getTime();
|
||||||
|
const result = await banAppUser(activeUser.userId, {
|
||||||
|
expires_at_ms: expiresAtMs,
|
||||||
|
reason: form.reason,
|
||||||
|
});
|
||||||
|
patchUsers([mergeUserStatus(activeUser, result, "banned")]);
|
||||||
|
setSelectedUserIds((current) => current.filter((userId) => userId !== activeUser.userId));
|
||||||
|
closeAction();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const batchBan = async () => {
|
const batchBan = async () => {
|
||||||
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
|
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
@ -231,7 +301,7 @@ export function useAppUsersPage() {
|
|||||||
}
|
}
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
confirmText: "封禁",
|
confirmText: "封禁",
|
||||||
message: `将封禁 ${users.length} 个用户。`,
|
message: `将永久封禁 ${users.length} 个用户。`,
|
||||||
title: "批量封禁用户",
|
title: "批量封禁用户",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
});
|
});
|
||||||
@ -241,7 +311,11 @@ export function useAppUsersPage() {
|
|||||||
|
|
||||||
setLoadingAction("batch-ban");
|
setLoadingAction("batch-ban");
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
|
const results = await Promise.allSettled(
|
||||||
|
users.map((user) =>
|
||||||
|
banAppUser(user.userId, { expires_at_ms: 0, reason: "admin_batch_permanent_ban" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
const updatedUsers = results
|
const updatedUsers = results
|
||||||
.map((result, index) =>
|
.map((result, index) =>
|
||||||
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
|
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
|
||||||
@ -270,6 +344,23 @@ export function useAppUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportUsers = async () => {
|
||||||
|
setLoadingAction("export");
|
||||||
|
try {
|
||||||
|
// 创建任务时仅传当前筛选快照;页码由后端固定为首批并逐页导出,避免只导出当前可见页。
|
||||||
|
const created = await createAppUserExport(filters);
|
||||||
|
showToast(`导出任务 #${created.jobId} 已创建`, "success");
|
||||||
|
const job = await waitAppUserExportJob(created.jobId);
|
||||||
|
const response = await downloadAppUserExport(created.jobId);
|
||||||
|
await downloadResponse(response, appUserExportFilename(created.snapshotAtMs || job.createdAtMs));
|
||||||
|
showToast("用户 CSV 已生成", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "导出用户失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runAction = async (action, successMessage, submitter) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -299,6 +390,7 @@ export function useAppUsersPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
activeUser,
|
activeUser,
|
||||||
|
banForm,
|
||||||
batchBan,
|
batchBan,
|
||||||
changeUserFilter,
|
changeUserFilter,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
@ -313,6 +405,7 @@ export function useAppUsersPage() {
|
|||||||
loadingAction,
|
loadingAction,
|
||||||
loadingCountries,
|
loadingCountries,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
|
levelForm,
|
||||||
locationFilterOptions,
|
locationFilterOptions,
|
||||||
locationFilterValue,
|
locationFilterValue,
|
||||||
openCountry,
|
openCountry,
|
||||||
@ -327,7 +420,9 @@ export function useAppUsersPage() {
|
|||||||
resetFilters,
|
resetFilters,
|
||||||
selectedUserIds,
|
selectedUserIds,
|
||||||
setCountryForm,
|
setCountryForm,
|
||||||
|
setBanForm,
|
||||||
setEditForm,
|
setEditForm,
|
||||||
|
setLevelForm,
|
||||||
setPage,
|
setPage,
|
||||||
setPasswordForm,
|
setPasswordForm,
|
||||||
setSelectedUserIds,
|
setSelectedUserIds,
|
||||||
@ -339,9 +434,12 @@ export function useAppUsersPage() {
|
|||||||
changeSort,
|
changeSort,
|
||||||
changeTimeRange,
|
changeTimeRange,
|
||||||
submitCountry,
|
submitCountry,
|
||||||
|
submitBan,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
|
submitLevels,
|
||||||
submitPassword,
|
submitPassword,
|
||||||
toggleBan,
|
toggleBan,
|
||||||
|
exportUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,3 +454,56 @@ function mergeUserStatus(currentUser, result, fallbackStatus) {
|
|||||||
status: result?.status || fallbackStatus,
|
status: result?.status || fallbackStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function levelFormFromUser(user) {
|
||||||
|
return {
|
||||||
|
charm: levelTrackForm(user?.levels?.charm),
|
||||||
|
reason: "",
|
||||||
|
wealth: levelTrackForm(user?.levels?.wealth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelTrackForm(level) {
|
||||||
|
const realLevel = Number(level?.realLevel || 0);
|
||||||
|
const currentTarget = Number(level?.temporaryTargetLevel || level?.displayLevel || realLevel + 1);
|
||||||
|
const expiresAtMs = Number(level?.expiresAtMs || 0);
|
||||||
|
const remainingDays = expiresAtMs > Date.now() ? Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 86400000)) : 7;
|
||||||
|
return {
|
||||||
|
durationDays: String(remainingDays),
|
||||||
|
enabled: false,
|
||||||
|
level: String(Math.min(50, Math.max(1, currentTarget))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitAppUserExportJob(jobId) {
|
||||||
|
const deadline = Date.now() + 120000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const job = await getAppUserExportJob(jobId);
|
||||||
|
if (job.status === "succeeded") {
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
if (["failed", "canceled", "cancelled"].includes(job.status)) {
|
||||||
|
throw new Error(job.error || "用户导出任务失败");
|
||||||
|
}
|
||||||
|
await delay(1500);
|
||||||
|
}
|
||||||
|
throw new Error("用户导出任务仍在处理中,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function futureDateTimeLocal(days) {
|
||||||
|
const date = new Date(Date.now() + days * 86400000);
|
||||||
|
const offsetMs = date.getTimezoneOffset() * 60000;
|
||||||
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appUserExportFilename(snapshotAtMs) {
|
||||||
|
const date = new Date(Number(snapshotAtMs || Date.now()));
|
||||||
|
const stamp = Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "snapshot";
|
||||||
|
return `app-users-${stamp}.csv`;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
|
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
|
||||||
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
||||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
@ -22,6 +25,13 @@ import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
|||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||||
|
import {
|
||||||
|
BanCountdown,
|
||||||
|
GenderValue,
|
||||||
|
LevelValue,
|
||||||
|
OperatorValue,
|
||||||
|
RolesValue,
|
||||||
|
} from "@/features/app-users/components/AppUserValues.jsx";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
@ -89,6 +99,42 @@ export function AppUserListPage() {
|
|||||||
}),
|
}),
|
||||||
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "gender",
|
||||||
|
label: "性别",
|
||||||
|
width: "minmax(72px, 0.4fr)",
|
||||||
|
render: (user) => <GenderValue gender={user.gender} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "age",
|
||||||
|
label: "年龄",
|
||||||
|
width: "minmax(72px, 0.4fr)",
|
||||||
|
render: (user) => (user.age === undefined || user.age === null ? "-" : `${user.age} 岁`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wealthLevel",
|
||||||
|
label: "富豪等级",
|
||||||
|
width: "minmax(118px, 0.7fr)",
|
||||||
|
render: (user) => <LevelValue level={user.levels?.wealth} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "charmLevel",
|
||||||
|
label: "魅力等级",
|
||||||
|
width: "minmax(118px, 0.7fr)",
|
||||||
|
render: (user) => <LevelValue level={user.levels?.charm} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gameLevel",
|
||||||
|
label: "游戏等级",
|
||||||
|
width: "minmax(96px, 0.55fr)",
|
||||||
|
render: (user) => <LevelValue level={user.levels?.game} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roles",
|
||||||
|
label: "身份",
|
||||||
|
width: "minmax(150px, 0.9fr)",
|
||||||
|
render: (user) => <RolesValue roles={user.roles} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "vip",
|
key: "vip",
|
||||||
label: "VIP",
|
label: "VIP",
|
||||||
@ -134,7 +180,30 @@ export function AppUserListPage() {
|
|||||||
value: page.status,
|
value: page.status,
|
||||||
onChange: page.changeStatus,
|
onChange: page.changeStatus,
|
||||||
}),
|
}),
|
||||||
render: (user) => <UserStatus status={user.status} />,
|
render: (user) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<UserStatus status={user.status} />
|
||||||
|
<BanCountdown ban={user.ban} status={user.status} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastLoginAtMs",
|
||||||
|
label: "最新成功登录",
|
||||||
|
width: "minmax(160px, 0.9fr)",
|
||||||
|
render: (user) => formatMillis(user.lastLoginAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastOperator",
|
||||||
|
label: "最新操作人",
|
||||||
|
width: "minmax(142px, 0.8fr)",
|
||||||
|
render: (user) => <OperatorValue operator={user.lastOperator} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastOperatedAtMs",
|
||||||
|
label: "最新操作时间",
|
||||||
|
width: "minmax(160px, 0.9fr)",
|
||||||
|
render: (user) => formatMillis(user.lastOperatedAtMs),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "time",
|
key: "time",
|
||||||
@ -181,6 +250,19 @@ export function AppUserListPage() {
|
|||||||
onChange={page.changeTimeRange}
|
onChange={page.changeTimeRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
{page.abilities.canExport ? (
|
||||||
|
<Button
|
||||||
|
disabled={page.loadingAction === "export"}
|
||||||
|
startIcon={<FileDownloadOutlined fontSize="small" />}
|
||||||
|
onClick={page.exportUsers}
|
||||||
|
>
|
||||||
|
{page.loadingAction === "export" ? "导出中" : "导出 CSV"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{page.abilities.canStatus ? (
|
{page.abilities.canStatus ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
||||||
@ -192,10 +274,11 @@ export function AppUserListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1220px"
|
minWidth="2380px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -212,15 +295,17 @@ export function AppUserListPage() {
|
|||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionModal
|
<AdminFormDialog
|
||||||
disabled={!page.abilities.canUpdate}
|
|
||||||
loading={page.loadingAction === "edit"}
|
loading={page.loadingAction === "edit"}
|
||||||
open={page.activeAction === "edit"}
|
open={page.activeAction === "edit"}
|
||||||
sectionTitle="用户资料"
|
size="standard"
|
||||||
|
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
|
||||||
|
submitLabel="保存资料"
|
||||||
title="编辑用户"
|
title="编辑用户"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitEdit}
|
onSubmit={page.abilities.canUpdate ? page.submitEdit : (event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
|
<AdminFormSection title="用户资料">
|
||||||
<TextField
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label="用户名称"
|
label="用户名称"
|
||||||
@ -246,6 +331,60 @@ export function AppUserListPage() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
</AdminFormSection>
|
||||||
|
<AdminFormSection
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
disabled={!page.abilities.canLevel || page.loadingAction === "levels"}
|
||||||
|
variant="primary"
|
||||||
|
onClick={page.submitLevels}
|
||||||
|
>
|
||||||
|
{page.loadingAction === "levels" ? "保存中" : "保存等级"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="等级调整"
|
||||||
|
>
|
||||||
|
<LevelAdjustmentFields page={page} />
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
|
||||||
|
<ActionModal
|
||||||
|
disabled={!page.abilities.canStatus}
|
||||||
|
loading={page.loadingAction === `status-${page.activeUser?.userId}`}
|
||||||
|
open={page.activeAction === "ban"}
|
||||||
|
sectionTitle="封禁信息"
|
||||||
|
title="封禁用户"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitBan}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canStatus}
|
||||||
|
label="封禁期限"
|
||||||
|
select
|
||||||
|
value={page.banForm.mode}
|
||||||
|
onChange={(event) => page.setBanForm({ ...page.banForm, mode: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="permanent">永久</MenuItem>
|
||||||
|
<MenuItem value="timed">指定截止时间</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{page.banForm.mode === "timed" ? (
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canStatus}
|
||||||
|
label="封禁截止时间"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
type="datetime-local"
|
||||||
|
value={page.banForm.expiresAtLocal}
|
||||||
|
onChange={(event) => page.setBanForm({ ...page.banForm, expiresAtLocal: event.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canStatus}
|
||||||
|
label="封禁原因"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={page.banForm.reason}
|
||||||
|
onChange={(event) => page.setBanForm({ ...page.banForm, reason: event.target.value })}
|
||||||
|
/>
|
||||||
</ActionModal>
|
</ActionModal>
|
||||||
|
|
||||||
<ActionModal
|
<ActionModal
|
||||||
@ -321,6 +460,70 @@ function CoinValue({ page, user }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LevelAdjustmentFields({ page }) {
|
||||||
|
const tracks = [
|
||||||
|
["wealth", "富豪等级"],
|
||||||
|
["charm", "魅力等级"],
|
||||||
|
];
|
||||||
|
const updateTrack = (track, patch) => {
|
||||||
|
page.setLevelForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[track]: { ...current[track], ...patch },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={styles.levelForm}>
|
||||||
|
{tracks.map(([track, label]) => {
|
||||||
|
const field = page.levelForm[track];
|
||||||
|
const current = page.activeUser?.levels?.[track];
|
||||||
|
return (
|
||||||
|
<div className={styles.levelTrackForm} key={track}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.enabled}
|
||||||
|
disabled={!page.abilities.canLevel}
|
||||||
|
onChange={(event) => updateTrack(track, { enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={`${label}(真实 Lv${Number(current?.realLevel || 0)},展示 Lv${Number(current?.displayLevel || 0)})`}
|
||||||
|
/>
|
||||||
|
<div className={styles.formGridTwo}>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canLevel || !field.enabled}
|
||||||
|
label="目标等级"
|
||||||
|
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={field.level}
|
||||||
|
onChange={(event) => updateTrack(track, { level: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canLevel || !field.enabled}
|
||||||
|
label="有效天数"
|
||||||
|
slotProps={{ htmlInput: { max: 36500, min: 1, step: 1 } }}
|
||||||
|
type="number"
|
||||||
|
value={field.durationDays}
|
||||||
|
onChange={(event) => updateTrack(track, { durationDays: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TextField
|
||||||
|
disabled
|
||||||
|
label="游戏等级(只读)"
|
||||||
|
value={`Lv${Number(page.activeUser?.levels?.game?.displayLevel || 0)}`}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canLevel}
|
||||||
|
label="调整原因"
|
||||||
|
value={page.levelForm.reason}
|
||||||
|
onChange={(event) => page.setLevelForm((current) => ({ ...current, reason: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VipValue({ vip }) {
|
function VipValue({ vip }) {
|
||||||
const level = Number(vip?.level || 0);
|
const level = Number(vip?.level || 0);
|
||||||
if (level <= 0 || !vip?.active) {
|
if (level <= 0 || !vip?.active) {
|
||||||
@ -350,10 +553,13 @@ function UserActions({ page, user }) {
|
|||||||
const banned = user.status === "banned" || user.status === "disabled";
|
const banned = user.status === "banned" || user.status === "disabled";
|
||||||
return (
|
return (
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate || page.abilities.canLevel ? (
|
||||||
<Tooltip arrow title="编辑">
|
<Tooltip arrow title={page.abilities.canUpdate ? "编辑" : "等级调整"}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
|
<IconButton
|
||||||
|
label={page.abilities.canUpdate ? "编辑" : "等级调整"}
|
||||||
|
onClick={() => page.openEdit(user)}
|
||||||
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { AppUserListPage } from "./AppUserListPage.jsx";
|
import { AppUserListPage } from "./AppUserListPage.jsx";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@ -26,9 +27,17 @@ afterEach(() => {
|
|||||||
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
||||||
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
render(<AppUserListPage />);
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserListPage />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("VIP888")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("公会长、BD")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "调整富豪等级列宽" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "调整最新成功登录列宽" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||||
|
|
||||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
||||||
@ -43,7 +52,11 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<AppUserListPage />);
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserListPage />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
|
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
|
||||||
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
||||||
@ -51,10 +64,49 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("edit dialog saves profile and temporary levels independently", () => {
|
||||||
|
const page = pageFixture({ activeAction: "edit", activeUser: userFixture() });
|
||||||
|
vi.mocked(useAppUsersPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserListPage />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("用户资料")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("等级调整")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("游戏等级(只读)")).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "保存等级" }));
|
||||||
|
expect(page.submitLevels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByRole("button", { name: "保存资料" }).closest("form"));
|
||||||
|
expect(page.submitEdit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level-only permission can open level adjustment without exposing country edit", () => {
|
||||||
|
const page = pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canLevel: true, canUpdate: false },
|
||||||
|
});
|
||||||
|
vi.mocked(useAppUsersPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserListPage />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "等级调整" }));
|
||||||
|
expect(page.openEdit).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
||||||
|
expect(screen.queryByRole("button", { name: "阿拉伯联合酋长国" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
function pageFixture(patch = {}) {
|
||||||
return {
|
return {
|
||||||
abilities: {
|
abilities: {
|
||||||
canCoinLedger: true,
|
canCoinLedger: true,
|
||||||
|
canExport: true,
|
||||||
|
canLevel: true,
|
||||||
canPassword: true,
|
canPassword: true,
|
||||||
canStatus: true,
|
canStatus: true,
|
||||||
canUpdate: true,
|
canUpdate: true,
|
||||||
@ -63,6 +115,7 @@ function pageFixture(patch = {}) {
|
|||||||
},
|
},
|
||||||
activeAction: "",
|
activeAction: "",
|
||||||
activeUser: null,
|
activeUser: null,
|
||||||
|
banForm: { expiresAtLocal: "", mode: "permanent", reason: "" },
|
||||||
batchBan: vi.fn(),
|
batchBan: vi.fn(),
|
||||||
changeLocationFilter: vi.fn(),
|
changeLocationFilter: vi.fn(),
|
||||||
changeQuery: vi.fn(),
|
changeQuery: vi.fn(),
|
||||||
@ -74,11 +127,17 @@ function pageFixture(patch = {}) {
|
|||||||
countryOptions: [],
|
countryOptions: [],
|
||||||
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
||||||
editForm: { avatar: "", gender: "", username: "" },
|
editForm: { avatar: "", gender: "", username: "" },
|
||||||
|
exportUsers: vi.fn(),
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingAction: "",
|
loadingAction: "",
|
||||||
loadingCountries: false,
|
loadingCountries: false,
|
||||||
loadingRegions: false,
|
loadingRegions: false,
|
||||||
|
levelForm: {
|
||||||
|
charm: { durationDays: "7", enabled: false, level: "4" },
|
||||||
|
reason: "",
|
||||||
|
wealth: { durationDays: "7", enabled: false, level: "5" },
|
||||||
|
},
|
||||||
locationFilterOptions: [],
|
locationFilterOptions: [],
|
||||||
locationFilterValue: "",
|
locationFilterValue: "",
|
||||||
openCoinLedger: mocks.openCoinLedger,
|
openCoinLedger: mocks.openCoinLedger,
|
||||||
@ -90,17 +149,22 @@ function pageFixture(patch = {}) {
|
|||||||
query: "",
|
query: "",
|
||||||
regionOptions: [],
|
regionOptions: [],
|
||||||
reload: vi.fn(),
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
selectedUserIds: [],
|
selectedUserIds: [],
|
||||||
|
setBanForm: vi.fn(),
|
||||||
setCountryForm: vi.fn(),
|
setCountryForm: vi.fn(),
|
||||||
setEditForm: vi.fn(),
|
setEditForm: vi.fn(),
|
||||||
|
setLevelForm: vi.fn(),
|
||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
setPasswordForm: vi.fn(),
|
setPasswordForm: vi.fn(),
|
||||||
setSelectedUserIds: vi.fn(),
|
setSelectedUserIds: vi.fn(),
|
||||||
sortBy: "created_at",
|
sortBy: "created_at",
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
status: "",
|
status: "",
|
||||||
|
submitBan: vi.fn(),
|
||||||
submitCountry: vi.fn(),
|
submitCountry: vi.fn(),
|
||||||
submitEdit: vi.fn(),
|
submitEdit: vi.fn(),
|
||||||
|
submitLevels: vi.fn(),
|
||||||
submitPassword: vi.fn(),
|
submitPassword: vi.fn(),
|
||||||
timeRange: { endMs: "", startMs: "" },
|
timeRange: { endMs: "", startMs: "" },
|
||||||
toggleBan: vi.fn(),
|
toggleBan: vi.fn(),
|
||||||
@ -110,15 +174,30 @@ function pageFixture(patch = {}) {
|
|||||||
|
|
||||||
function userFixture() {
|
function userFixture() {
|
||||||
return {
|
return {
|
||||||
|
age: 24,
|
||||||
avatar: "",
|
avatar: "",
|
||||||
|
ban: { active: false, expiresAtMs: 0, permanent: false },
|
||||||
coin: 223437,
|
coin: 223437,
|
||||||
country: "AE",
|
country: "AE",
|
||||||
countryDisplayName: "阿拉伯联合酋长国",
|
countryDisplayName: "阿拉伯联合酋长国",
|
||||||
createdAtMs: 1779322523000,
|
createdAtMs: 1779322523000,
|
||||||
|
defaultDisplayUserId: "163337",
|
||||||
displayUserId: "163337",
|
displayUserId: "163337",
|
||||||
|
gender: "female",
|
||||||
lastActiveAtMs: 1781325798000,
|
lastActiveAtMs: 1781325798000,
|
||||||
|
lastLoginAtMs: 1781325700000,
|
||||||
|
lastOperatedAtMs: 1781325750000,
|
||||||
|
lastOperator: { action: "update", adminId: "7", name: "雷乐" },
|
||||||
|
levels: {
|
||||||
|
charm: { displayLevel: 3, realLevel: 3, track: "charm" },
|
||||||
|
game: { displayLevel: 6, realLevel: 6, track: "game" },
|
||||||
|
wealth: { displayLevel: 4, realLevel: 4, track: "wealth" },
|
||||||
|
},
|
||||||
|
prettyDisplayUserId: "VIP888",
|
||||||
|
prettyId: "pretty-888",
|
||||||
regionId: 2,
|
regionId: 2,
|
||||||
regionName: "中东区",
|
regionName: "中东区",
|
||||||
|
roles: ["公会长", "BD"],
|
||||||
status: "active",
|
status: "active",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "d",
|
username: "d",
|
||||||
|
|||||||
@ -15,5 +15,7 @@ export function useAppUserAbilities() {
|
|||||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appUserView),
|
canView: can(PERMISSIONS.appUserView),
|
||||||
|
canLevel: can(PERMISSIONS.appUserLevel),
|
||||||
|
canExport: can(PERMISSIONS.appUserExport),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/features/app-users/schema.test.ts
Normal file
28
src/features/app-users/schema.test.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { prettyDisplayIDGrantSchema } from "./schema";
|
||||||
|
|
||||||
|
describe("pretty display ID grant schema", () => {
|
||||||
|
test("accepts arbitrary Unicode display content", () => {
|
||||||
|
// 管理端只保留展示文本的非空和长度约束,避免 Unicode 字体、符号和 emoji 被客户端正则错误拦截。
|
||||||
|
const payload = parseForm(prettyDisplayIDGrantSchema, {
|
||||||
|
displayUserId: " 𝕸⃝é中⚡️™ ",
|
||||||
|
durationDays: "1",
|
||||||
|
reason: "Unicode 靓号",
|
||||||
|
targetUserId: "163001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.displayUserId).toBe("𝕸⃝é中⚡️™");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts supplementary Unicode characters as one character", () => {
|
||||||
|
const payload = parseForm(prettyDisplayIDGrantSchema, {
|
||||||
|
displayUserId: "😀".repeat(64),
|
||||||
|
durationDays: "",
|
||||||
|
reason: "",
|
||||||
|
targetUserId: "163001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(payload.displayUserId)).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,8 +14,64 @@ export const appUserPasswordSchema = z.object({
|
|||||||
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
|
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记,空格、标点、emoji 都在前端先拦截。
|
const temporaryLevelTrackSchema = z.object({
|
||||||
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
|
durationDays: z.string().trim().optional().default("7"),
|
||||||
|
enabled: z.boolean().optional().default(false),
|
||||||
|
level: z.string().trim().optional().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。
|
||||||
|
export const appUserLevelAdjustmentSchema = z
|
||||||
|
.object({
|
||||||
|
charm: temporaryLevelTrackSchema,
|
||||||
|
reason: z.string().trim().max(128, "调整原因不能超过 128 个字符").optional().default(""),
|
||||||
|
wealth: temporaryLevelTrackSchema,
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
const enabledTracks = [
|
||||||
|
["wealth", value.wealth],
|
||||||
|
["charm", value.charm],
|
||||||
|
] as const;
|
||||||
|
if (!enabledTracks.some(([, track]) => track.enabled)) {
|
||||||
|
context.addIssue({ code: "custom", message: "至少选择一个等级轨道", path: ["wealth", "enabled"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enabledTracks.forEach(([name, track]) => {
|
||||||
|
if (!track.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = Number(track.level);
|
||||||
|
if (!Number.isInteger(level) || level < 1 || level > 50) {
|
||||||
|
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] });
|
||||||
|
}
|
||||||
|
const durationDays = Number(track.durationDays);
|
||||||
|
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "有效天数必须是 1 到 36500 的整数",
|
||||||
|
path: [name, "durationDays"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// expires_at_ms=0 仅由“永久”模式产生;指定截止时间必须在浏览器当前时刻之后,服务端仍以 UTC 再次校验。
|
||||||
|
export const appUserBanSchema = z
|
||||||
|
.object({
|
||||||
|
expiresAtLocal: z.string().trim().optional().default(""),
|
||||||
|
mode: z.enum(["permanent", "timed"], "请选择封禁期限"),
|
||||||
|
reason: z.string().trim().max(512, "封禁原因不能超过 512 个字符").optional().default(""),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value.mode !== "timed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiresAtMs = new Date(value.expiresAtLocal).getTime();
|
||||||
|
if (!value.expiresAtLocal || !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
||||||
|
context.addIssue({ code: "custom", message: "截止时间必须晚于当前时间", path: ["expiresAtLocal"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const prettyRuleValues = [
|
const prettyRuleValues = [
|
||||||
"aa",
|
"aa",
|
||||||
"aaa",
|
"aaa",
|
||||||
@ -77,14 +133,16 @@ export const prettyDisplayIDGenerateSchema = z.object({
|
|||||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 后台发放允许管理员输入非数字靓号;durationDays 为空或 0 都会转成 duration_ms=0,表示长期持有。
|
// 靓号展示内容必须原样支持任意 Unicode 字符,不能以字母数字正则过滤字体字形、表情或符号;仅去除首尾空白、限制长度并拦截控制字符,服务端继续负责唯一性和持久化安全校验。
|
||||||
export const prettyDisplayIDGrantSchema = z.object({
|
export const prettyDisplayIDGrantSchema = z.object({
|
||||||
displayUserId: z
|
displayUserId: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "请输入靓号内容")
|
.min(1, "请输入靓号内容")
|
||||||
.max(64, "靓号不能超过 64 个字符")
|
// String.length 会把补充平面 Unicode 字符计为两个 UTF-16 单元,必须按 code point 计数,才能和服务端的 rune 上限一致。
|
||||||
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
|
.refine((value) => Array.from(value).length <= 64, "靓号不能超过 64 个字符")
|
||||||
|
// 控制字符不是展示字形,保留会破坏列表、日志和协议载荷;符号、emoji 和格式化字形仍可通过。
|
||||||
|
.refine((value) => !/\p{Cc}/u.test(value), "靓号不能包含控制字符"),
|
||||||
durationDays: z
|
durationDays: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@ -111,6 +169,8 @@ export const prettyDisplayIDStatusSchema = z.object({
|
|||||||
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
||||||
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
||||||
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
||||||
|
export type AppUserLevelAdjustmentForm = z.infer<typeof appUserLevelAdjustmentSchema>;
|
||||||
|
export type AppUserBanForm = z.infer<typeof appUserBanSchema>;
|
||||||
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
|
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
|
||||||
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
||||||
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
|
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
|
||||||
|
|||||||
74
src/features/auth/loginRedirect.js
Normal file
74
src/features/auth/loginRedirect.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const redirectParam = "redirect";
|
||||||
|
const defaultRedirectTarget = "/overview";
|
||||||
|
const documentEntryPrefixes = ["/finance", "/databi", "/ops-center", "/money"];
|
||||||
|
|
||||||
|
export function locationToPath(location) {
|
||||||
|
if (!location?.pathname) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${location.pathname}${location.search || ""}${location.hash || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLoginRedirectPath(location) {
|
||||||
|
const target = normalizeRedirectTarget(locationToPath(location));
|
||||||
|
if (!target) {
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
return `/login?${redirectParam}=${encodeURIComponent(target)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLoginRedirectTarget(location, fallback = defaultRedirectTarget) {
|
||||||
|
const queryTarget = new URLSearchParams(location?.search || "").get(redirectParam);
|
||||||
|
return normalizeRedirectTarget(queryTarget) || normalizeRedirectTarget(locationToPath(location?.state?.from)) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDocumentEntryTarget(target) {
|
||||||
|
const normalized = normalizeRedirectTarget(target);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { pathname } = new URL(normalized, currentOrigin());
|
||||||
|
return documentEntryPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectAfterLogin(target, navigate, targetWindow = window) {
|
||||||
|
const normalized = normalizeRedirectTarget(target) || defaultRedirectTarget;
|
||||||
|
if (isDocumentEntryTarget(normalized)) {
|
||||||
|
// Subsystems are separate HTML entries; SPA navigation would hit the admin fallback route.
|
||||||
|
targetWindow.location.replace(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(normalized, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRedirectTarget(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw || raw.startsWith("//")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(raw, currentOrigin());
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.origin !== currentOrigin()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
if (!target.startsWith("/") || isLoginTarget(target)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoginTarget(target) {
|
||||||
|
return new URL(target, currentOrigin()).pathname === "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOrigin() {
|
||||||
|
return globalThis.window?.location?.origin || "http://localhost";
|
||||||
|
}
|
||||||
67
src/features/auth/loginRedirect.test.js
Normal file
67
src/features/auth/loginRedirect.test.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
buildLoginRedirectPath,
|
||||||
|
isDocumentEntryTarget,
|
||||||
|
locationToPath,
|
||||||
|
redirectAfterLogin,
|
||||||
|
resolveLoginRedirectTarget
|
||||||
|
} from "./loginRedirect.js";
|
||||||
|
|
||||||
|
test("builds login path with the full current path", () => {
|
||||||
|
const loginPath = buildLoginRedirectPath({
|
||||||
|
hash: "#summary",
|
||||||
|
pathname: "/finance/",
|
||||||
|
search: "?view=rechargeDetails&app=lalu"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loginPath).toBe("/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails%26app%3Dlalu%23summary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves login redirect from query before router state", () => {
|
||||||
|
const target = resolveLoginRedirectTarget({
|
||||||
|
pathname: "/login",
|
||||||
|
search: "?redirect=%2Ffinance%2F%3Fview%3Drecon",
|
||||||
|
state: { from: { pathname: "/system/users", search: "?page=2" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toBe("/finance/?view=recon");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to router state and preserves search and hash", () => {
|
||||||
|
const target = resolveLoginRedirectTarget({
|
||||||
|
pathname: "/login",
|
||||||
|
search: "",
|
||||||
|
state: { from: { hash: "#role", pathname: "/system/users", search: "?page=2" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toBe("/system/users?page=2#role");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsafe or looping redirect targets", () => {
|
||||||
|
expect(resolveLoginRedirectTarget({ search: "?redirect=https%3A%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
|
||||||
|
expect(resolveLoginRedirectTarget({ search: "?redirect=%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
|
||||||
|
expect(resolveLoginRedirectTarget({ search: "?redirect=%2Flogin%3Fredirect%3D%252Ffinance%252F" })).toBe("/overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects document entry targets", () => {
|
||||||
|
expect(isDocumentEntryTarget("/finance/?view=overview")).toBe(true);
|
||||||
|
expect(isDocumentEntryTarget("/databi/social/?view=funnel")).toBe(true);
|
||||||
|
expect(isDocumentEntryTarget("/ops-center/")).toBe(true);
|
||||||
|
expect(isDocumentEntryTarget("/overview")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirectAfterLogin reloads document entries and uses router navigation for admin routes", () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const targetWindow = { location: { replace: vi.fn() } };
|
||||||
|
|
||||||
|
redirectAfterLogin("/finance/?view=rechargeDetails", navigate, targetWindow);
|
||||||
|
expect(targetWindow.location.replace).toHaveBeenCalledWith("/finance/?view=rechargeDetails");
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
redirectAfterLogin("/system/users?page=2", navigate, targetWindow);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/system/users?page=2", { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("locationToPath returns an empty string for missing location", () => {
|
||||||
|
expect(locationToPath(null)).toBe("");
|
||||||
|
});
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
import LoginOutlined from "@mui/icons-material/LoginOutlined";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { isDocumentEntryTarget, redirectAfterLogin, resolveLoginRedirectTarget } from "@/features/auth/loginRedirect.js";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
@ -15,9 +16,19 @@ export function LoginPage() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const from = location.state?.from?.pathname || "/overview";
|
const from = resolveLoginRedirectTarget(location);
|
||||||
|
const needsDocumentRedirect = isDocumentEntryTarget(from);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && needsDocumentRedirect) {
|
||||||
|
redirectAfterLogin(from, navigate);
|
||||||
|
}
|
||||||
|
}, [from, navigate, needsDocumentRedirect, user]);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
if (needsDocumentRedirect) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return <Navigate to={from} replace />;
|
return <Navigate to={from} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +38,7 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login({ username, password });
|
await login({ username, password });
|
||||||
showToast("登录成功", "success");
|
showToast("登录成功", "success");
|
||||||
navigate(from, { replace: true });
|
redirectAfterLogin(from, navigate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "登录失败", "error");
|
showToast(err.message || "登录失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
|
||||||
import { getFrequentSecondLevelMenus } from "@/app/navigation/menuUsage.js";
|
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
|
||||||
|
|
||||||
export function DashboardFrequentMenus({ menus = [] }) {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const frequentMenus = useMemo(() => getFrequentSecondLevelMenus({ menus, user }), [menus, user]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="frequent-menu-section" aria-label="常用二级菜单">
|
|
||||||
<div className="frequent-menu-head">
|
|
||||||
<div>
|
|
||||||
<h2>常用页面</h2>
|
|
||||||
<p>本地缓存的二级菜单访问次数</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{frequentMenus.length ? (
|
|
||||||
<div className="frequent-menu-grid">
|
|
||||||
{frequentMenus.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="frequent-menu-card"
|
|
||||||
key={item.code}
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(item.path)}
|
|
||||||
>
|
|
||||||
<span className="frequent-menu-card__icon">{Icon ? <Icon fontSize="small" /> : null}</span>
|
|
||||||
<span className="frequent-menu-card__group">{item.parentLabel}</span>
|
|
||||||
<strong>{item.label}</strong>
|
|
||||||
<span className="frequent-menu-card__meta">
|
|
||||||
进入 {item.count || 0} 次 · <TimeText value={item.lastVisitedAtMs} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="frequent-menu-empty">暂无本地访问记录</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -8,7 +8,7 @@ export function DashboardToolbar({ onRefresh, onRangeChange, overview, range })
|
|||||||
const { formatMillis } = useTimeZone();
|
const { formatMillis } = useTimeZone();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHead title="系统概览" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
|
<PageHead title="工作台" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
|
||||||
<TimeRangeSelect value={range} onChange={onRangeChange} />
|
<TimeRangeSelect value={range} onChange={onRangeChange} />
|
||||||
<Button onClick={onRefresh}>
|
<Button onClick={onRefresh}>
|
||||||
<Refresh fontSize="small" />
|
<Refresh fontSize="small" />
|
||||||
|
|||||||
58
src/features/dashboard/components/DashboardWorkspaces.jsx
Normal file
58
src/features/dashboard/components/DashboardWorkspaces.jsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import AccountBalanceOutlined from "@mui/icons-material/AccountBalanceOutlined";
|
||||||
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const WORKSPACE_ENTRIES = [
|
||||||
|
{
|
||||||
|
description: "财务管理",
|
||||||
|
href: "/finance/",
|
||||||
|
icon: AccountBalanceOutlined,
|
||||||
|
label: "财务工作台",
|
||||||
|
permission: PERMISSIONS.financeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "运营管理",
|
||||||
|
href: "/databi/social/",
|
||||||
|
icon: InsightsOutlined,
|
||||||
|
label: "运营工作台",
|
||||||
|
permission: PERMISSIONS.overviewView,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPermittedWorkspaceEntries(can) {
|
||||||
|
// 两个工作台是独立 HTML 入口,展示权限必须与各入口后端接口的既有权限保持一致,避免仅靠前端隐藏造成权限口径漂移。
|
||||||
|
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWorkspaces() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const entries = getPermittedWorkspaceEntries(can);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="workspace-entry-section" aria-label="工作台入口">
|
||||||
|
<div className="workspace-entry-head">
|
||||||
|
<h2>工作台</h2>
|
||||||
|
</div>
|
||||||
|
{entries.length ? (
|
||||||
|
<div className="workspace-entry-grid">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const Icon = entry.icon;
|
||||||
|
return (
|
||||||
|
// 使用普通链接触发完整文档导航,确保 Vite 的多入口页面加载各自的 HTML 和应用运行时。
|
||||||
|
<a className="workspace-entry-card" href={entry.href} key={entry.href}>
|
||||||
|
<span className="workspace-entry-card__icon">
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</span>
|
||||||
|
<span className="workspace-entry-card__group">{entry.description}</span>
|
||||||
|
<strong>{entry.label}</strong>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="workspace-entry-empty">当前无可访问工作台</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
import { DashboardWorkspaces } from "./DashboardWorkspaces.jsx";
|
||||||
|
|
||||||
|
const can = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||||
|
useAuth: () => ({ can }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DashboardWorkspaces", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
can.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows each workspace only when its existing permission is granted", () => {
|
||||||
|
can.mockImplementation((permission) => permission === PERMISSIONS.financeView);
|
||||||
|
|
||||||
|
render(<DashboardWorkspaces />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
|
||||||
|
expect(screen.queryByRole("link", { name: /运营工作台/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes the operations workspace to the Social BI document entry", () => {
|
||||||
|
can.mockImplementation((permission) => permission === PERMISSIONS.overviewView);
|
||||||
|
|
||||||
|
render(<DashboardWorkspaces />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
|
||||||
|
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,40 +5,31 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-section {
|
.workspace-entry-section {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head {
|
.workspace-entry-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head h2 {
|
.workspace-entry-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--admin-font-size-lg);
|
font-size: var(--admin-font-size-lg);
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head p {
|
.workspace-entry-grid {
|
||||||
margin: var(--space-1) 0 0;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequent-menu-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card {
|
.workspace-entry-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 112px;
|
min-height: 128px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
@ -48,6 +39,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
text-decoration: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--motion-base) var(--ease-standard),
|
border-color var(--motion-base) var(--ease-standard),
|
||||||
@ -55,13 +47,19 @@
|
|||||||
transform var(--motion-base) var(--ease-emphasized);
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card:hover {
|
.workspace-entry-card:hover,
|
||||||
|
.workspace-entry-card:focus-visible {
|
||||||
border-color: var(--primary-border-strong);
|
border-color: var(--primary-border-strong);
|
||||||
box-shadow: var(--shadow-panel);
|
box-shadow: var(--shadow-panel);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card__icon {
|
.workspace-entry-card:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-card__icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@ -72,13 +70,13 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card__group {
|
.workspace-entry-card__group {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size-sm);
|
font-size: var(--admin-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card strong {
|
.workspace-entry-card strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -88,12 +86,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card__meta {
|
.workspace-entry-empty {
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: var(--admin-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequent-menu-empty {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { useOutletContext } from "react-router-dom";
|
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
||||||
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
|
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const { menus = [] } = useOutletContext() || {};
|
return <DashboardWorkspaces />;
|
||||||
|
|
||||||
return <DashboardFrequentMenus menus={menus} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
|||||||
|
|
||||||
export const dashboardRoutes = [
|
export const dashboardRoutes = [
|
||||||
{
|
{
|
||||||
label: "系统概览",
|
label: "工作台",
|
||||||
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
|
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
|
||||||
menuCode: MENU_CODES.overview,
|
menuCode: MENU_CODES.overview,
|
||||||
pageKey: "overview",
|
pageKey: "overview",
|
||||||
|
|||||||
@ -13,12 +13,18 @@ import {
|
|||||||
disableRegion,
|
disableRegion,
|
||||||
listAgencies,
|
listAgencies,
|
||||||
listBDs,
|
listBDs,
|
||||||
|
listCoinSellerSubApplications,
|
||||||
|
listCoinSellerSubSellers,
|
||||||
listCoinSellers,
|
listCoinSellers,
|
||||||
listCountries,
|
listCountries,
|
||||||
listHosts,
|
listHosts,
|
||||||
listManagers,
|
listManagers,
|
||||||
listRegions,
|
listRegions,
|
||||||
|
getJob,
|
||||||
|
renameCountryCode,
|
||||||
replaceRegionCountries,
|
replaceRegionCountries,
|
||||||
|
approveCoinSellerSubApplication,
|
||||||
|
rejectCoinSellerSubApplication,
|
||||||
setCoinSellerStatus,
|
setCoinSellerStatus,
|
||||||
updateBDLeaderPositionAlias,
|
updateBDLeaderPositionAlias,
|
||||||
updateCountry,
|
updateCountry,
|
||||||
@ -62,8 +68,16 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
region_id: 7,
|
region_id: 7,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" });
|
await createManager({
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
|
canBlockUser: false,
|
||||||
|
canGrantBadge: false,
|
||||||
|
canGrantVip: true,
|
||||||
|
contact: "+63",
|
||||||
|
targetUserId: "1003",
|
||||||
|
});
|
||||||
await updateManager("1003", {
|
await updateManager("1003", {
|
||||||
|
canAddBdLeaderInRegion: true,
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: false,
|
canGrantVip: false,
|
||||||
@ -146,6 +160,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
|
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
|
||||||
expect(managerCreateInit?.method).toBe("POST");
|
expect(managerCreateInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantBadge: false,
|
canGrantBadge: false,
|
||||||
canGrantVip: true,
|
canGrantVip: true,
|
||||||
@ -155,6 +170,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
|
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
|
||||||
expect(managerUpdateInit?.method).toBe("PUT");
|
expect(managerUpdateInit?.method).toBe("PUT");
|
||||||
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
||||||
|
canAddBdLeaderInRegion: true,
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
canGrantVip: false,
|
canGrantVip: false,
|
||||||
@ -198,6 +214,54 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin seller sub application APIs use generated admin paths", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await listCoinSellerSubSellers("3001", { page: 1, page_size: 10 });
|
||||||
|
await listCoinSellerSubApplications({
|
||||||
|
keyword: "163001",
|
||||||
|
page: 2,
|
||||||
|
page_size: 10,
|
||||||
|
parent_user_id: "3001",
|
||||||
|
status: "pending",
|
||||||
|
target_user_id: "3002",
|
||||||
|
});
|
||||||
|
await approveCoinSellerSubApplication("sub-app-1", {
|
||||||
|
commandId: "approve-sub-app-test",
|
||||||
|
reason: "ok",
|
||||||
|
});
|
||||||
|
await rejectCoinSellerSubApplication("sub-app-2", {
|
||||||
|
commandId: "reject-sub-app-test",
|
||||||
|
reason: "bad",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [subSellerUrl, subSellerInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
const [applicationUrl, applicationInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
|
const [approveUrl, approveInit] = vi.mocked(fetch).mock.calls[2];
|
||||||
|
const [rejectUrl, rejectInit] = vi.mocked(fetch).mock.calls[3];
|
||||||
|
|
||||||
|
expect(String(subSellerUrl)).toContain("/api/v1/admin/coin-sellers/3001/sub-sellers?");
|
||||||
|
expect(String(subSellerUrl)).toContain("page=1");
|
||||||
|
expect(subSellerInit?.method).toBe("GET");
|
||||||
|
expect(String(applicationUrl)).toContain("/api/v1/admin/coin-seller-sub-applications?");
|
||||||
|
expect(String(applicationUrl)).toContain("keyword=163001");
|
||||||
|
expect(String(applicationUrl)).toContain("parent_user_id=3001");
|
||||||
|
expect(String(applicationUrl)).toContain("target_user_id=3002");
|
||||||
|
expect(String(applicationUrl)).toContain("status=pending");
|
||||||
|
expect(applicationInit?.method).toBe("GET");
|
||||||
|
expect(String(approveUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-1/approve");
|
||||||
|
expect(approveInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(approveInit?.body))).toMatchObject({ commandId: "approve-sub-app-test" });
|
||||||
|
expect(String(rejectUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-2/reject");
|
||||||
|
expect(rejectInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(rejectInit?.body))).toMatchObject({ commandId: "reject-sub-app-test" });
|
||||||
|
});
|
||||||
|
|
||||||
test("country and region APIs use generated admin paths", async () => {
|
test("country and region APIs use generated admin paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -212,6 +276,13 @@ test("country and region APIs use generated admin paths", async () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
|
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
|
||||||
|
await renameCountryCode(12, {
|
||||||
|
countryCode: "USA",
|
||||||
|
countryDisplayName: "United States",
|
||||||
|
countryName: "United States",
|
||||||
|
sortOrder: 10,
|
||||||
|
});
|
||||||
|
await getJob(99);
|
||||||
await listRegions({ status: "active" });
|
await listRegions({ status: "active" });
|
||||||
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
|
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
|
||||||
await replaceRegionCountries(7, { countries: ["US", "CA"] });
|
await replaceRegionCountries(7, { countries: ["US", "CA"] });
|
||||||
@ -220,10 +291,12 @@ test("country and region APIs use generated admin paths", async () => {
|
|||||||
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
|
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
|
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
|
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
|
||||||
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
|
const [countryRenameUrl, countryRenameInit] = vi.mocked(fetch).mock.calls[3];
|
||||||
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
|
const [jobUrl, jobInit] = vi.mocked(fetch).mock.calls[4];
|
||||||
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
|
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[5];
|
||||||
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
|
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||||
|
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[7];
|
||||||
|
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[8];
|
||||||
|
|
||||||
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
|
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
|
||||||
expect(String(countryListUrl)).toContain("enabled=true");
|
expect(String(countryListUrl)).toContain("enabled=true");
|
||||||
@ -232,6 +305,12 @@ test("country and region APIs use generated admin paths", async () => {
|
|||||||
expect(countryCreateInit?.method).toBe("POST");
|
expect(countryCreateInit?.method).toBe("POST");
|
||||||
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
|
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
|
||||||
expect(countryUpdateInit?.method).toBe("PATCH");
|
expect(countryUpdateInit?.method).toBe("PATCH");
|
||||||
|
expect(JSON.parse(String(countryUpdateInit?.body))).not.toHaveProperty("countryCode");
|
||||||
|
expect(String(countryRenameUrl)).toContain("/api/v1/admin/countries/12/rename-code");
|
||||||
|
expect(countryRenameInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(countryRenameInit?.body))).toMatchObject({ countryCode: "USA", sortOrder: 10 });
|
||||||
|
expect(String(jobUrl)).toContain("/api/v1/jobs/99");
|
||||||
|
expect(jobInit?.method).toBe("GET");
|
||||||
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
|
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
|
||||||
expect(String(regionListUrl)).toContain("status=active");
|
expect(String(regionListUrl)).toContain("status=active");
|
||||||
expect(regionListInit?.method).toBe("GET");
|
expect(regionListInit?.method).toBe("GET");
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
AgencyHostAddPayload,
|
AgencyHostAddPayload,
|
||||||
AgencyJoinEnabledPayload,
|
AgencyJoinEnabledPayload,
|
||||||
AgencyStatusPayload,
|
AgencyStatusPayload,
|
||||||
|
AdminJobDto,
|
||||||
ApiList,
|
ApiList,
|
||||||
ApiPage,
|
ApiPage,
|
||||||
BDLeaderPositionAliasPayload,
|
BDLeaderPositionAliasPayload,
|
||||||
@ -14,13 +15,18 @@ import type {
|
|||||||
CoinSellerDto,
|
CoinSellerDto,
|
||||||
CoinSellerSalaryRatesDto,
|
CoinSellerSalaryRatesDto,
|
||||||
CoinSellerSalaryRatesPayload,
|
CoinSellerSalaryRatesPayload,
|
||||||
|
CoinSellerSubApplicationDto,
|
||||||
|
CoinSellerSubApplicationReviewPayload,
|
||||||
|
CoinSellerSubApplicationReviewResultDto,
|
||||||
CoinSellerStockCreditDto,
|
CoinSellerStockCreditDto,
|
||||||
CoinSellerStockCreditPayload,
|
CoinSellerStockCreditPayload,
|
||||||
CoinSellerStockDebitDto,
|
CoinSellerStockDebitDto,
|
||||||
CoinSellerStockDebitPayload,
|
CoinSellerStockDebitPayload,
|
||||||
CoinSellerStatusPayload,
|
CoinSellerStatusPayload,
|
||||||
|
CountryCodeRenameJobDto,
|
||||||
CountryDto,
|
CountryDto,
|
||||||
CountryPayload,
|
CountryPayload,
|
||||||
|
CountryRenameCodePayload,
|
||||||
CountryUpdatePayload,
|
CountryUpdatePayload,
|
||||||
CreateAgencyPayload,
|
CreateAgencyPayload,
|
||||||
CreateAgencyResultDto,
|
CreateAgencyResultDto,
|
||||||
@ -78,6 +84,27 @@ export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameCountryCode(
|
||||||
|
countryId: EntityId,
|
||||||
|
payload: CountryRenameCodePayload,
|
||||||
|
): Promise<CountryCodeRenameJobDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.renameCountryCode;
|
||||||
|
return apiRequest<CountryCodeRenameJobDto, CountryRenameCodePayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.renameCountryCode, { country_id: countryId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(jobId: EntityId): Promise<AdminJobDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getJob;
|
||||||
|
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
|
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
|
||||||
const endpoint = API_ENDPOINTS.enableCountry;
|
const endpoint = API_ENDPOINTS.enableCountry;
|
||||||
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
|
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
|
||||||
@ -192,10 +219,13 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
|
|||||||
|
|
||||||
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
|
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
|
||||||
const endpoint = API_ENDPOINTS.updateManager;
|
const endpoint = API_ENDPOINTS.updateManager;
|
||||||
return apiRequest<ManagerDto, UpdateManagerPayload>(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), {
|
return apiRequest<ManagerDto, UpdateManagerPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }),
|
||||||
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
|
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
|
||||||
@ -214,6 +244,28 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listCoinSellerSubSellers(userId: EntityId, query: PageQuery = {}): Promise<ApiPage<CoinSellerDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listCoinSellerSubSellers;
|
||||||
|
return apiRequest<ApiPage<CoinSellerDto>>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.listCoinSellerSubSellers, { user_id: userId }),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCoinSellerSubApplications(query: PageQuery = {}): Promise<ApiPage<CoinSellerSubApplicationDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listCoinSellerSubApplications;
|
||||||
|
return apiRequest<ApiPage<CoinSellerSubApplicationDto>>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.listCoinSellerSubApplications),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listHostWithdrawals(query: PageQuery = {}): Promise<ApiPage<HostWithdrawalDto>> {
|
export function listHostWithdrawals(query: PageQuery = {}): Promise<ApiPage<HostWithdrawalDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listHostWithdrawals;
|
const endpoint = API_ENDPOINTS.listHostWithdrawals;
|
||||||
return apiRequest<ApiPage<HostWithdrawalDto>>(apiEndpointPath(API_OPERATIONS.listHostWithdrawals), {
|
return apiRequest<ApiPage<HostWithdrawalDto>>(apiEndpointPath(API_OPERATIONS.listHostWithdrawals), {
|
||||||
@ -312,6 +364,34 @@ export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function approveCoinSellerSubApplication(
|
||||||
|
applicationId: EntityId,
|
||||||
|
payload: CoinSellerSubApplicationReviewPayload,
|
||||||
|
): Promise<CoinSellerSubApplicationReviewResultDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.approveCoinSellerSubApplication;
|
||||||
|
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.approveCoinSellerSubApplication, { application_id: applicationId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectCoinSellerSubApplication(
|
||||||
|
applicationId: EntityId,
|
||||||
|
payload: CoinSellerSubApplicationReviewPayload,
|
||||||
|
): Promise<CoinSellerSubApplicationReviewResultDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.rejectCoinSellerSubApplication;
|
||||||
|
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.rejectCoinSellerSubApplication, { application_id: applicationId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function creditCoinSellerStock(
|
export function creditCoinSellerStock(
|
||||||
userId: EntityId,
|
userId: EntityId,
|
||||||
payload: CoinSellerStockCreditPayload,
|
payload: CoinSellerStockCreditPayload,
|
||||||
|
|||||||
@ -13,10 +13,13 @@ export function HostOrgStatus({ value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusTone(status) {
|
function getStatusTone(status) {
|
||||||
if (status === "active") {
|
if (status === "active" || status === "approved") {
|
||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
if (status === "closed" || status === "disabled") {
|
if (status === "pending") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
if (status === "closed" || status === "disabled" || status === "rejected") {
|
||||||
return "danger";
|
return "danger";
|
||||||
}
|
}
|
||||||
return "stopped";
|
return "stopped";
|
||||||
|
|||||||
@ -1,53 +1,69 @@
|
|||||||
export const agencyStatusFilters = [
|
export const agencyStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["closed", "已关闭"]
|
["closed", "已关闭"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const countryEnabledFilters = [
|
export const countryEnabledFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["enabled", "启用"],
|
["enabled", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const regionStatusFilters = [
|
export const regionStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const bdStatusFilters = [
|
export const bdStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const managerStatusFilters = [
|
export const managerStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const hostStatusFilters = [
|
export const hostStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const coinSellerStatusFilters = [
|
export const coinSellerStatusFilters = [
|
||||||
["", "全部"],
|
["", "全部"],
|
||||||
["active", "启用"],
|
["active", "启用"],
|
||||||
["disabled", "停用"]
|
["disabled", "停用"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const coinSellerSubApplicationStatusFilters = [
|
||||||
|
["", "全部"],
|
||||||
|
["pending", "待审核"],
|
||||||
|
["approved", "已通过"],
|
||||||
|
["rejected", "已拒绝"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const coinSellerSubApplicationStatusLabels = {
|
||||||
|
approved: "已通过",
|
||||||
|
pending: "待审核",
|
||||||
|
rejected: "已拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
export const hostSourceLabels = {
|
export const hostSourceLabels = {
|
||||||
agency_application: "Agency 申请",
|
agency_application: "Agency 申请",
|
||||||
agency_invitation: "Agency 邀请",
|
agency_invitation: "Agency 邀请",
|
||||||
admin_create_agency: "后台创建 Agency"
|
admin_create_agency: "后台创建 Agency",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const statusLabels = {
|
export const statusLabels = {
|
||||||
active: "启用",
|
active: "启用",
|
||||||
|
approved: "已通过",
|
||||||
closed: "已关闭",
|
closed: "已关闭",
|
||||||
disabled: "停用"
|
disabled: "停用",
|
||||||
|
pending: "待审核",
|
||||||
|
rejected: "已拒绝",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
debitCoinSellerStock,
|
debitCoinSellerStock,
|
||||||
getCoinSellerSalaryRates,
|
getCoinSellerSalaryRates,
|
||||||
listCoinSellers,
|
listCoinSellers,
|
||||||
|
listCoinSellerSubSellers,
|
||||||
replaceCoinSellerSalaryRates,
|
replaceCoinSellerSalaryRates,
|
||||||
setCoinSellerStatus,
|
setCoinSellerStatus,
|
||||||
} from "@/features/host-org/api";
|
} from "@/features/host-org/api";
|
||||||
@ -23,8 +24,15 @@ import {
|
|||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", targetUserId: "" });
|
const emptyCreateForm = () => ({
|
||||||
|
canManageSubCoinSellers: true,
|
||||||
|
commandId: makeCommandId("coin-seller"),
|
||||||
|
contact: "",
|
||||||
|
reason: "",
|
||||||
|
targetUserId: "",
|
||||||
|
});
|
||||||
const emptyEditForm = () => ({
|
const emptyEditForm = () => ({
|
||||||
|
canManageSubCoinSellers: true,
|
||||||
commandId: makeCommandId("coin-seller-status"),
|
commandId: makeCommandId("coin-seller-status"),
|
||||||
contact: "",
|
contact: "",
|
||||||
reason: "",
|
reason: "",
|
||||||
@ -72,6 +80,9 @@ export function useHostCoinSellersPage() {
|
|||||||
const [loadingRates, setLoadingRates] = useState(false);
|
const [loadingRates, setLoadingRates] = useState(false);
|
||||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [loadingSubSellers, setLoadingSubSellers] = useState(false);
|
||||||
|
const [subSellerData, setSubSellerData] = useState(emptyData);
|
||||||
|
const [subSellerPage, setSubSellerPageState] = useState(1);
|
||||||
const [contactSavingIds, setContactSavingIds] = useState({});
|
const [contactSavingIds, setContactSavingIds] = useState({});
|
||||||
const [sellerRowPatches, setSellerRowPatches] = useState({});
|
const [sellerRowPatches, setSellerRowPatches] = useState({});
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@ -150,6 +161,7 @@ export function useHostCoinSellersPage() {
|
|||||||
}
|
}
|
||||||
setSelectedSeller(seller);
|
setSelectedSeller(seller);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
|
||||||
commandId: makeCommandId("coin-seller-status"),
|
commandId: makeCommandId("coin-seller-status"),
|
||||||
contact: seller.contact || "",
|
contact: seller.contact || "",
|
||||||
reason: "update coin seller contact",
|
reason: "update coin seller contact",
|
||||||
@ -185,6 +197,15 @@ export function useHostCoinSellersPage() {
|
|||||||
setActiveAction("ledger");
|
setActiveAction("ledger");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSubSellerList = async (seller) => {
|
||||||
|
if (!seller?.userId || !abilities.canView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedSeller(seller);
|
||||||
|
setActiveAction("sub-sellers");
|
||||||
|
await loadSubSellers(seller.userId, 1);
|
||||||
|
};
|
||||||
|
|
||||||
const openRateSettings = async () => {
|
const openRateSettings = async () => {
|
||||||
if (!abilities.canExchangeRate) {
|
if (!abilities.canExchangeRate) {
|
||||||
return;
|
return;
|
||||||
@ -204,6 +225,31 @@ export function useHostCoinSellersPage() {
|
|||||||
setSelectedSeller(null);
|
setSelectedSeller(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSubSellers = async (sellerUserId = selectedSeller?.userId, nextPage = subSellerPage) => {
|
||||||
|
if (!sellerUserId) {
|
||||||
|
setSubSellerData(emptyData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingSubSellers(true);
|
||||||
|
try {
|
||||||
|
const nextData = await listCoinSellerSubSellers(sellerUserId, {
|
||||||
|
page: nextPage,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
setSubSellerData(nextData || emptyData);
|
||||||
|
setSubSellerPageState(nextPage);
|
||||||
|
} catch (err) {
|
||||||
|
setSubSellerData(emptyData);
|
||||||
|
showToast(err.message || "加载子币商列表失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingSubSellers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSubSellerPage = async (nextPage) => {
|
||||||
|
await loadSubSellers(selectedSeller?.userId, nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
const changeRateRegionId = async (value) => {
|
const changeRateRegionId = async (value) => {
|
||||||
setRateRegionId(value);
|
setRateRegionId(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
@ -265,6 +311,7 @@ export function useHostCoinSellersPage() {
|
|||||||
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
|
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
|
||||||
"币商已更新",
|
"币商已更新",
|
||||||
async () => {
|
async () => {
|
||||||
|
// 编辑接口复用 status 命令,提交时带上权限字段,避免只改联系方式时把子币商权限丢给后端默认值处理。
|
||||||
const payload = parseForm(coinSellerStatusSchema, editForm);
|
const payload = parseForm(coinSellerStatusSchema, editForm);
|
||||||
const { targetUserId, ...body } = payload;
|
const { targetUserId, ...body } = payload;
|
||||||
await setCoinSellerStatus(targetUserId, body);
|
await setCoinSellerStatus(targetUserId, body);
|
||||||
@ -286,7 +333,9 @@ export function useHostCoinSellersPage() {
|
|||||||
const sellerID = seller.userId;
|
const sellerID = seller.userId;
|
||||||
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
|
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
|
||||||
try {
|
try {
|
||||||
|
// 联系方式行内保存同样走状态接口,显式保留当前权限位,避免局部保存覆盖行级权限。
|
||||||
await setCoinSellerStatus(sellerID, {
|
await setCoinSellerStatus(sellerID, {
|
||||||
|
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
|
||||||
commandId: makeCommandId("coin-seller-contact"),
|
commandId: makeCommandId("coin-seller-contact"),
|
||||||
contact,
|
contact,
|
||||||
reason: "update coin seller contact",
|
reason: "update coin seller contact",
|
||||||
@ -313,6 +362,37 @@ export function useHostCoinSellersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSubCoinSellerPermission = async (seller, nextEnabled = !seller?.canManageSubCoinSellers) => {
|
||||||
|
if (!abilities.canUpdate || !seller?.userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((seller.canManageSubCoinSellers === true) === nextEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(
|
||||||
|
`coin-seller-sub-permission-${seller.userId}`,
|
||||||
|
nextEnabled ? "子币商权限已开启" : "子币商权限已关闭",
|
||||||
|
async () => {
|
||||||
|
// 子权限开关复用币商状态接口,必须保留 status/contact,防止权限更新被解释成状态重置。
|
||||||
|
await setCoinSellerStatus(seller.userId, {
|
||||||
|
canManageSubCoinSellers: nextEnabled,
|
||||||
|
commandId: makeCommandId("coin-seller-sub-permission"),
|
||||||
|
contact: seller.contact || "",
|
||||||
|
reason: "update coin seller sub permission",
|
||||||
|
status: seller.status || "active",
|
||||||
|
});
|
||||||
|
setSellerRowPatches((current) => ({
|
||||||
|
...current,
|
||||||
|
[seller.userId]: {
|
||||||
|
...(current[seller.userId] || {}),
|
||||||
|
canManageSubCoinSellers: nextEnabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await reload();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const submitStockCredit = async (event) => {
|
const submitStockCredit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selectedSeller?.userId) {
|
if (!selectedSeller?.userId) {
|
||||||
@ -373,7 +453,9 @@ export function useHostCoinSellersPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
|
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
|
||||||
|
// 状态开关只改变启停状态,显式保留权限位,避免后端按缺省值改写子币商授权。
|
||||||
await setCoinSellerStatus(seller.userId, {
|
await setCoinSellerStatus(seller.userId, {
|
||||||
|
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
|
||||||
commandId: makeCommandId("coin-seller-status"),
|
commandId: makeCommandId("coin-seller-status"),
|
||||||
reason: "",
|
reason: "",
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
@ -409,6 +491,7 @@ export function useHostCoinSellersPage() {
|
|||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
loadingSubSellers,
|
||||||
loadingRates,
|
loadingRates,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
openRateSettings,
|
openRateSettings,
|
||||||
@ -417,6 +500,7 @@ export function useHostCoinSellersPage() {
|
|||||||
openSellerLedger,
|
openSellerLedger,
|
||||||
openStockCredit,
|
openStockCredit,
|
||||||
openStockDebit,
|
openStockDebit,
|
||||||
|
openSubSellerList,
|
||||||
page,
|
page,
|
||||||
regionId,
|
regionId,
|
||||||
regionOptions,
|
regionOptions,
|
||||||
@ -434,9 +518,12 @@ export function useHostCoinSellersPage() {
|
|||||||
setPage,
|
setPage,
|
||||||
setStockForm,
|
setStockForm,
|
||||||
setStockDebitForm,
|
setStockDebitForm,
|
||||||
|
setSubSellerPage,
|
||||||
status,
|
status,
|
||||||
stockDebitForm,
|
stockDebitForm,
|
||||||
stockForm,
|
stockForm,
|
||||||
|
subSellerData,
|
||||||
|
subSellerPage,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
submitRateSettings,
|
submitRateSettings,
|
||||||
@ -444,6 +531,7 @@ export function useHostCoinSellersPage() {
|
|||||||
submitEditSeller,
|
submitEditSeller,
|
||||||
submitStockCredit,
|
submitStockCredit,
|
||||||
submitStockDebit,
|
submitStockDebit,
|
||||||
|
toggleSubCoinSellerPermission,
|
||||||
toggleSeller,
|
toggleSeller,
|
||||||
updateRateTier,
|
updateRateTier,
|
||||||
userFilter,
|
userFilter,
|
||||||
|
|||||||
@ -2,9 +2,17 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { createCountry, disableCountry, enableCountry, listCountries, updateCountry } from "@/features/host-org/api";
|
import {
|
||||||
|
createCountry,
|
||||||
|
disableCountry,
|
||||||
|
enableCountry,
|
||||||
|
getJob,
|
||||||
|
listCountries,
|
||||||
|
renameCountryCode,
|
||||||
|
updateCountry,
|
||||||
|
} from "@/features/host-org/api";
|
||||||
import { useCountryAbilities } from "@/features/host-org/permissions.js";
|
import { useCountryAbilities } from "@/features/host-org/permissions.js";
|
||||||
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
|
import { countryCreateSchema, countryRenameCodeSchema, countryUpdateSchema } from "@/features/host-org/schema";
|
||||||
|
|
||||||
const emptyCountryForm = () => ({
|
const emptyCountryForm = () => ({
|
||||||
countryCode: "",
|
countryCode: "",
|
||||||
@ -86,15 +94,56 @@ export function useHostCountriesPage() {
|
|||||||
const submitCountry = async (event) => {
|
const submitCountry = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const isEdit = activeAction === "edit";
|
const isEdit = activeAction === "edit";
|
||||||
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
|
if (!isEdit) {
|
||||||
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
|
await runAction("country-create", "国家已创建", async () => {
|
||||||
const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
|
const payload = parseForm(countryCreateSchema, countryForm);
|
||||||
|
const data = await createCountry(payload);
|
||||||
setCountryForm(emptyCountryForm());
|
setCountryForm(emptyCountryForm());
|
||||||
setEditingCountry(null);
|
setEditingCountry(null);
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
await reload();
|
await reload();
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCountryCode = normalizeCountryCode(editingCountry?.countryCode);
|
||||||
|
const nextCountryCode = normalizeCountryCode(countryForm.countryCode);
|
||||||
|
if (currentCountryCode !== nextCountryCode) {
|
||||||
|
await runCountryCodeRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAction("country-edit", "国家已更新", async () => {
|
||||||
|
const { countryCode: _countryCode, enabled: _enabled, ...formWithoutImmutableFields } = countryForm;
|
||||||
|
const payload = parseForm(countryUpdateSchema, formWithoutImmutableFields);
|
||||||
|
const data = await updateCountry(editingCountry.countryId, payload);
|
||||||
|
setCountryForm(emptyCountryForm());
|
||||||
|
setEditingCountry(null);
|
||||||
|
setActiveAction("");
|
||||||
|
await reload();
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCountryCodeRename = async () => {
|
||||||
|
setLoadingAction("country-rename");
|
||||||
|
try {
|
||||||
|
const payload = parseForm(countryRenameCodeSchema, countryForm);
|
||||||
|
const job = await renameCountryCode(editingCountry.countryId, payload);
|
||||||
|
showToast(`国家码重命名任务已创建:#${job.jobId}`, "success");
|
||||||
|
setCountryForm(emptyCountryForm());
|
||||||
|
setEditingCountry(null);
|
||||||
|
setActiveAction("");
|
||||||
|
// 国家码会同步 user/admin/room 多个当前态,只有 job succeeded 后才刷新列表,避免短暂显示新旧码混杂。
|
||||||
|
await waitCountryCodeRenameJob(job.jobId);
|
||||||
|
await reload();
|
||||||
|
showToast("国家码重命名完成", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "国家码重命名失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
|
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
|
||||||
@ -144,6 +193,33 @@ export function useHostCountriesPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitCountryCodeRenameJob(jobId) {
|
||||||
|
const deadline = Date.now() + 120000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const job = await getJob(jobId);
|
||||||
|
if (job.status === "succeeded") {
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
if (job.status === "failed" || job.status === "canceled") {
|
||||||
|
throw new Error(job.error || "国家码重命名任务失败");
|
||||||
|
}
|
||||||
|
await delay(1500);
|
||||||
|
}
|
||||||
|
throw new Error("国家码重命名任务仍在处理中,请稍后刷新查看");
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCountryCode(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function filterCountries(items, query) {
|
function filterCountries(items, query) {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
|
|||||||
127
src/features/host-org/hooks/useHostCountriesPage.test.jsx
Normal file
127
src/features/host-org/hooks/useHostCountriesPage.test.jsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { useHostCountriesPage } from "./useHostCountriesPage.js";
|
||||||
|
import { getJob, renameCountryCode, updateCountry } from "@/features/host-org/api";
|
||||||
|
import { useCountryAbilities } from "@/features/host-org/permissions.js";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
reload: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/api", () => ({
|
||||||
|
createCountry: vi.fn(),
|
||||||
|
disableCountry: vi.fn(),
|
||||||
|
enableCountry: vi.fn(),
|
||||||
|
getJob: vi.fn(),
|
||||||
|
listCountries: vi.fn(),
|
||||||
|
renameCountryCode: vi.fn(),
|
||||||
|
updateCountry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/permissions.js", () => ({
|
||||||
|
useCountryAbilities: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||||
|
useAdminQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country edit without code change uses metadata update endpoint", async () => {
|
||||||
|
mockHookDeps();
|
||||||
|
vi.mocked(updateCountry).mockResolvedValue({ countryId: 12, countryCode: "PH" });
|
||||||
|
const { result } = renderHook(() => useHostCountriesPage());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditCountry(countryFixture());
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitCountry({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateCountry).toHaveBeenCalledWith(
|
||||||
|
12,
|
||||||
|
expect.not.objectContaining({
|
||||||
|
countryCode: expect.anything(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(renameCountryCode).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.reload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country edit with code change creates rename job and polls until success", async () => {
|
||||||
|
mockHookDeps();
|
||||||
|
vi.mocked(renameCountryCode).mockResolvedValue({
|
||||||
|
countryId: 12,
|
||||||
|
jobId: 99,
|
||||||
|
newCountryCode: "PHL",
|
||||||
|
oldCountryCode: "PH",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
vi.mocked(getJob).mockResolvedValue({ id: 99, status: "succeeded", type: "country-code-rename" });
|
||||||
|
const { result } = renderHook(() => useHostCountriesPage());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditCountry(countryFixture());
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.setCountryForm({ ...result.current.countryForm, countryCode: "phl" });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitCountry({ preventDefault: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renameCountryCode).toHaveBeenCalledWith(
|
||||||
|
12,
|
||||||
|
expect.objectContaining({
|
||||||
|
countryCode: "PHL",
|
||||||
|
countryDisplayName: "菲律宾",
|
||||||
|
countryName: "Philippines",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(getJob).toHaveBeenCalledWith(99);
|
||||||
|
expect(updateCountry).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.showToast).toHaveBeenCalledWith("国家码重命名完成", "success");
|
||||||
|
expect(mocks.reload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockHookDeps() {
|
||||||
|
vi.mocked(useCountryAbilities).mockReturnValue({
|
||||||
|
canCreate: true,
|
||||||
|
canStatus: true,
|
||||||
|
canUpdate: true,
|
||||||
|
canView: true,
|
||||||
|
});
|
||||||
|
vi.mocked(useToast).mockReturnValue({ showToast: mocks.showToast });
|
||||||
|
vi.mocked(useAdminQuery).mockReturnValue({
|
||||||
|
data: { items: [], total: 0 },
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
reload: mocks.reload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFixture() {
|
||||||
|
return {
|
||||||
|
countryCode: "PH",
|
||||||
|
countryDisplayName: "菲律宾",
|
||||||
|
countryId: 12,
|
||||||
|
countryName: "Philippines",
|
||||||
|
enabled: true,
|
||||||
|
flag: "🇵🇭",
|
||||||
|
isoAlpha3: "PHL",
|
||||||
|
isoNumeric: "608",
|
||||||
|
phoneCountryCode: "+63",
|
||||||
|
sortOrder: 1770,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -17,29 +17,44 @@ export const managerPermissionFields = [
|
|||||||
"canGrantVip",
|
"canGrantVip",
|
||||||
"canUpdateUserLevel",
|
"canUpdateUserLevel",
|
||||||
"canAddBdLeader",
|
"canAddBdLeader",
|
||||||
|
"canAddBdLeaderInRegion",
|
||||||
"canAddAdmin",
|
"canAddAdmin",
|
||||||
"canAddSuperadmin",
|
"canAddSuperadmin",
|
||||||
"canBlockUser",
|
"canBlockUser",
|
||||||
"canTransferUserCountry",
|
"canTransferUserCountry",
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
|
const managerPermissionDefault = (field) => field !== "canAddBdLeaderInRegion";
|
||||||
|
|
||||||
const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermissions() });
|
const managerPermissionEnabled = (source, field) => {
|
||||||
|
const value = source?.[field];
|
||||||
|
return value === undefined || value === null ? managerPermissionDefault(field) : value !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyPermissions = () =>
|
||||||
|
Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionDefault(field)]));
|
||||||
|
|
||||||
|
const emptyManagerForm = (regionExpansionConfigurable = false) => ({
|
||||||
|
bdLeaderRegionExpansionConfigurable: regionExpansionConfigurable,
|
||||||
|
contact: "",
|
||||||
|
targetUserId: "",
|
||||||
|
...emptyPermissions(),
|
||||||
|
});
|
||||||
|
|
||||||
const managerPermissionPayload = (form) =>
|
const managerPermissionPayload = (form) =>
|
||||||
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
|
Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(form, field)]));
|
||||||
|
|
||||||
const managerStatusPayload = (item, status) => ({
|
const managerStatusPayload = (item, status) => ({
|
||||||
contact: item?.contact || "",
|
contact: item?.contact || "",
|
||||||
status,
|
status,
|
||||||
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
|
...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
const managerFormFromItem = (item) => ({
|
const managerFormFromItem = (item) => ({
|
||||||
|
bdLeaderRegionExpansionConfigurable: item?.bdLeaderRegionExpansionConfigurable === true,
|
||||||
contact: item?.contact || "",
|
contact: item?.contact || "",
|
||||||
targetUserId: String(item?.userId || ""),
|
targetUserId: String(item?.userId || ""),
|
||||||
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
|
...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useHostManagersPage() {
|
export function useHostManagersPage() {
|
||||||
@ -118,7 +133,7 @@ export function useHostManagersPage() {
|
|||||||
});
|
});
|
||||||
showToast("经理已添加", "success");
|
showToast("经理已添加", "success");
|
||||||
}
|
}
|
||||||
setManagerForm(emptyManagerForm());
|
setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setEditingManager(null);
|
setEditingManager(null);
|
||||||
await reload();
|
await reload();
|
||||||
@ -132,12 +147,12 @@ export function useHostManagersPage() {
|
|||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setEditingManager(null);
|
setEditingManager(null);
|
||||||
setManagerForm(emptyManagerForm());
|
setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openManagerForm = () => {
|
const openManagerForm = () => {
|
||||||
setEditingManager(null);
|
setEditingManager(null);
|
||||||
setManagerForm(emptyManagerForm());
|
setManagerForm(emptyManagerForm(data.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true));
|
||||||
setActiveAction("manager");
|
setActiveAction("manager");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import {
|
||||||
|
approveCoinSellerSubApplication,
|
||||||
|
listCoinSellerSubApplications,
|
||||||
|
rejectCoinSellerSubApplication,
|
||||||
|
} from "@/features/host-org/api";
|
||||||
|
import { useCoinSellerSubApplicationAbilities } from "@/features/host-org/permissions.js";
|
||||||
|
import { coinSellerSubApplicationReviewSchema } from "@/features/host-org/schema";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
|
const emptyReviewForm = (decision = "approved") => ({
|
||||||
|
commandId: makeCommandId(`coin-seller-sub-${decision}`),
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useHostSubCoinSellerApplicationsPage() {
|
||||||
|
const abilities = useCoinSellerSubApplicationAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState("pending");
|
||||||
|
const [parentUserId, setParentUserId] = useState("");
|
||||||
|
const [targetUserId, setTargetUserId] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [selectedApplication, setSelectedApplication] = useState(null);
|
||||||
|
const [reviewDecision, setReviewDecision] = useState("approved");
|
||||||
|
const [reviewForm, setReviewForm] = useState(emptyReviewForm);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword,
|
||||||
|
parent_user_id: parentUserId,
|
||||||
|
status,
|
||||||
|
target_user_id: targetUserId,
|
||||||
|
}),
|
||||||
|
[keyword, parentUserId, status, targetUserId],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data = emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = usePaginatedQuery({
|
||||||
|
errorMessage: "加载子币商申请列表失败",
|
||||||
|
fetcher: listCoinSellerSubApplications,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["host-org", "coin-seller-sub-applications", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeKeyword = (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeParentUserId = (value) => {
|
||||||
|
setParentUserId(String(value || "").trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTargetUserId = (value) => {
|
||||||
|
setTargetUserId(String(value || "").trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setStatus("");
|
||||||
|
setParentUserId("");
|
||||||
|
setTargetUserId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReview = (application, decision) => {
|
||||||
|
if (!application?.applicationId || !abilities.canAudit || application.status !== "pending") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 审核 commandId 与列表刷新分离,避免弹窗重复提交时把另一次审核误认为同一请求。
|
||||||
|
setSelectedApplication(application);
|
||||||
|
setReviewDecision(decision);
|
||||||
|
setReviewForm(emptyReviewForm(decision));
|
||||||
|
setActiveAction("review");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAction = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setSelectedApplication(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReview = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedApplication?.applicationId || !abilities.canAudit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = parseForm(coinSellerSubApplicationReviewSchema, reviewForm);
|
||||||
|
const submitter =
|
||||||
|
reviewDecision === "approved" ? approveCoinSellerSubApplication : rejectCoinSellerSubApplication;
|
||||||
|
setLoadingAction(`review-${selectedApplication.applicationId}`);
|
||||||
|
try {
|
||||||
|
await submitter(selectedApplication.applicationId, payload);
|
||||||
|
showToast(reviewDecision === "approved" ? "子币商申请已通过" : "子币商申请已拒绝", "success");
|
||||||
|
closeAction();
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "审核子币商申请失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeAction,
|
||||||
|
changeKeyword,
|
||||||
|
changeParentUserId,
|
||||||
|
changeStatus,
|
||||||
|
changeTargetUserId,
|
||||||
|
closeAction,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
keyword,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
openReview,
|
||||||
|
page,
|
||||||
|
parentUserId,
|
||||||
|
resetFilters,
|
||||||
|
reviewDecision,
|
||||||
|
reviewForm,
|
||||||
|
selectedApplication,
|
||||||
|
setPage,
|
||||||
|
setReviewForm,
|
||||||
|
status,
|
||||||
|
submitReview,
|
||||||
|
targetUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCommandId(prefix) {
|
||||||
|
return `${prefix}-${Date.now()}`;
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import Add from "@mui/icons-material/Add";
|
|||||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
AdminFormReadOnlyField,
|
AdminFormReadOnlyField,
|
||||||
AdminFormSection,
|
AdminFormSection,
|
||||||
|
AdminFormSwitchField,
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
@ -76,6 +78,39 @@ const baseColumns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const subSellerColumns = [
|
||||||
|
{
|
||||||
|
key: "seller",
|
||||||
|
label: "子币商",
|
||||||
|
render: (item) => <SellerIdentity item={item} />,
|
||||||
|
width: "minmax(220px, 1.3fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "regionId",
|
||||||
|
label: "区域",
|
||||||
|
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
||||||
|
width: "minmax(140px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => item.status || "-",
|
||||||
|
width: "minmax(90px, 0.5fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "merchantBalance",
|
||||||
|
label: "币商余额",
|
||||||
|
render: (item) => formatNumber(item.merchantBalance),
|
||||||
|
width: "minmax(120px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs),
|
||||||
|
width: "minmax(170px, 1fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function HostCoinSellersPage() {
|
export function HostCoinSellersPage() {
|
||||||
const page = useHostCoinSellersPage();
|
const page = useHostCoinSellersPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
@ -91,13 +126,25 @@ export function HostCoinSellersPage() {
|
|||||||
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
render: (item) => <SellerStatusSwitch item={item} page={page} />,
|
||||||
width: "minmax(90px, 0.6fr)",
|
width: "minmax(90px, 0.6fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "canManageSubCoinSellers",
|
||||||
|
label: "子币商权限",
|
||||||
|
render: (item) => <SubCoinSellerPermissionSwitch item={item} page={page} />,
|
||||||
|
width: "minmax(120px, 0.7fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subCoinSellerCount",
|
||||||
|
label: "子币商数量",
|
||||||
|
render: (item) => <SubCoinSellerCountButton item={item} page={page} />,
|
||||||
|
width: "minmax(120px, 0.7fr)",
|
||||||
|
},
|
||||||
...baseColumns.slice(2),
|
...baseColumns.slice(2),
|
||||||
page.abilities.canStockCredit || page.abilities.canLedger
|
page.abilities.canUpdate || page.abilities.canStockCredit || page.abilities.canLedger
|
||||||
? {
|
? {
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => <SellerActions item={item} page={page} />,
|
render: (item) => <SellerActions item={item} page={page} />,
|
||||||
width: "minmax(130px, 0.65fr)",
|
width: "minmax(160px, 0.75fr)",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
@ -167,7 +214,7 @@ export function HostCoinSellersPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="930px"
|
minWidth="1080px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -208,6 +255,12 @@ export function HostCoinSellersPage() {
|
|||||||
value={page.createForm.contact}
|
value={page.createForm.contact}
|
||||||
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
|
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={page.createForm.canManageSubCoinSellers !== false}
|
||||||
|
disabled={createDisabled}
|
||||||
|
label="子币商权限"
|
||||||
|
onChange={(checked) => page.setCreateForm({ ...page.createForm, canManageSubCoinSellers: checked })}
|
||||||
|
/>
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<HostOrgActionModal
|
<HostOrgActionModal
|
||||||
@ -226,6 +279,12 @@ export function HostCoinSellersPage() {
|
|||||||
value={page.editForm.contact}
|
value={page.editForm.contact}
|
||||||
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
|
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<AdminFormSwitchField
|
||||||
|
checked={page.editForm.canManageSubCoinSellers === true}
|
||||||
|
disabled={updateDisabled}
|
||||||
|
label="子币商权限"
|
||||||
|
onChange={(checked) => page.setEditForm({ ...page.editForm, canManageSubCoinSellers: checked })}
|
||||||
|
/>
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<HostOrgActionModal
|
<HostOrgActionModal
|
||||||
@ -358,7 +417,9 @@ export function HostCoinSellersPage() {
|
|||||||
required
|
required
|
||||||
minRows={2}
|
minRows={2}
|
||||||
value={stockDebitForm.reason}
|
value={stockDebitForm.reason}
|
||||||
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
|
onChange={(event) =>
|
||||||
|
page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
@ -379,6 +440,35 @@ export function HostCoinSellersPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
|
|
||||||
|
<SideDrawer
|
||||||
|
className={styles.ledgerDrawer}
|
||||||
|
contentClassName={styles.ledgerDrawerBody}
|
||||||
|
open={page.activeAction === "sub-sellers"}
|
||||||
|
title={`子币商列表 - ${sellerDrawerTitle(page.selectedSeller)}`}
|
||||||
|
width="wide"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
>
|
||||||
|
<DataState loading={page.loadingSubSellers}>
|
||||||
|
<HostOrgTable
|
||||||
|
columns={subSellerColumns}
|
||||||
|
context={{ regionOptions: page.regionOptions }}
|
||||||
|
items={page.subSellerData.items || []}
|
||||||
|
minWidth="860px"
|
||||||
|
pagination={
|
||||||
|
Number(page.subSellerData.total || 0) > 0
|
||||||
|
? {
|
||||||
|
page: page.subSellerPage,
|
||||||
|
pageSize: page.subSellerData.pageSize || 50,
|
||||||
|
total: page.subSellerData.total,
|
||||||
|
onPageChange: page.setSubSellerPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.userId}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</SideDrawer>
|
||||||
|
|
||||||
<SideDrawer
|
<SideDrawer
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@ -482,6 +572,33 @@ function SellerStatusSwitch({ item, page }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SubCoinSellerPermissionSwitch({ item, page }) {
|
||||||
|
return (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={item.canManageSubCoinSellers === true}
|
||||||
|
checkedLabel="开"
|
||||||
|
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-sub-permission-${item.userId}`}
|
||||||
|
inputProps={{ "aria-label": item.canManageSubCoinSellers === true ? "关闭子币商权限" : "开启子币商权限" }}
|
||||||
|
uncheckedLabel="关"
|
||||||
|
onChange={(event) => page.toggleSubCoinSellerPermission(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubCoinSellerCountButton({ item, page }) {
|
||||||
|
const count = Number(item.subCoinSellerCount || 0);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
disabled={!page.abilities.canView || page.loadingSubSellers}
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => page.openSubSellerList(item)}
|
||||||
|
>
|
||||||
|
{formatNumber(count)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SellerContact({ item, page }) {
|
function SellerContact({ item, page }) {
|
||||||
const contact = String(item.contact || "").trim();
|
const contact = String(item.contact || "").trim();
|
||||||
return (
|
return (
|
||||||
@ -505,6 +622,15 @@ function SellerContact({ item, page }) {
|
|||||||
function SellerActions({ item, page }) {
|
function SellerActions({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
|
{page.abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={page.loadingAction === `coin-seller-edit-${item.userId}`}
|
||||||
|
label="编辑币商"
|
||||||
|
onClick={() => page.openEditSeller(item)}
|
||||||
|
>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
{page.abilities.canLedger ? (
|
{page.abilities.canLedger ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
label="币商流水"
|
label="币商流水"
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSel
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
ledgerTable: vi.fn(),
|
ledgerTable: vi.fn(),
|
||||||
openSellerLedger: vi.fn(),
|
openSellerLedger: vi.fn(),
|
||||||
|
openSubSellerList: vi.fn(),
|
||||||
|
toggleSubCoinSellerPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
|
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
|
||||||
@ -41,9 +43,45 @@ test("coin seller table shows cumulative USDT and hides creator id column", () =
|
|||||||
|
|
||||||
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
|
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("累充USDT")).toBeInTheDocument();
|
expect(screen.getByText("累充USDT")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("子币商权限")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("子币商数量")).toBeInTheDocument();
|
||||||
expect(screen.getByText("12.5")).toBeInTheDocument();
|
expect(screen.getByText("12.5")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin seller sub seller count opens direct child seller drawer", () => {
|
||||||
|
const seller = sellerFixture({ subCoinSellerCount: 2 });
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "2" }));
|
||||||
|
|
||||||
|
expect(mocks.openSubSellerList).toHaveBeenCalledWith(seller);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller sub permission switch toggles through the page hook", () => {
|
||||||
|
const seller = sellerFixture();
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canUpdate: true },
|
||||||
|
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
const switchInput = screen.getByRole("switch", { name: "关闭子币商权限" });
|
||||||
|
expect(switchInput).toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(switchInput);
|
||||||
|
|
||||||
|
expect(mocks.toggleSubCoinSellerPermission).toHaveBeenCalledWith(seller, false);
|
||||||
|
});
|
||||||
|
|
||||||
test("coin seller balance and cumulative USDT headers request sorting", () => {
|
test("coin seller balance and cumulative USDT headers request sorting", () => {
|
||||||
const changeSort = vi.fn();
|
const changeSort = vi.fn();
|
||||||
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
|
||||||
@ -83,7 +121,84 @@ test("coin seller ledger drawer passes locked seller id to shared table", () =>
|
|||||||
render(<HostCoinSellersPage />);
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
|
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
|
||||||
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
|
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller sub seller drawer renders approved direct children", () => {
|
||||||
|
const seller = sellerFixture({ subCoinSellerCount: 1 });
|
||||||
|
const child = sellerFixture({
|
||||||
|
displayUserId: "168933",
|
||||||
|
merchantBalance: 1200,
|
||||||
|
subCoinSellerCount: 0,
|
||||||
|
userId: "3002",
|
||||||
|
username: "Child seller",
|
||||||
|
});
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
activeAction: "sub-sellers",
|
||||||
|
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
|
||||||
|
selectedSeller: seller,
|
||||||
|
subSellerData: { items: [child], page: 1, pageSize: 50, total: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("子币商列表 - Flower92 / 164425")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Child seller")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("168933")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller create form includes sub permission switch", () => {
|
||||||
|
const setCreateForm = vi.fn();
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canCreate: true },
|
||||||
|
activeAction: "create",
|
||||||
|
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
|
||||||
|
setCreateForm,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
|
||||||
|
expect(switchInput).toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(switchInput);
|
||||||
|
|
||||||
|
expect(setCreateForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
canManageSubCoinSellers: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller edit form preserves sub permission field", () => {
|
||||||
|
const setEditForm = vi.fn();
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canUpdate: true },
|
||||||
|
activeAction: "edit",
|
||||||
|
editForm: { canManageSubCoinSellers: false, contact: "+63", targetUserId: "3001" },
|
||||||
|
setEditForm,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
|
||||||
|
expect(switchInput).not.toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(switchInput);
|
||||||
|
|
||||||
|
expect(setEditForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
canManageSubCoinSellers: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
function pageFixture(patch = {}) {
|
||||||
@ -105,9 +220,9 @@ function pageFixture(patch = {}) {
|
|||||||
changeStatus: vi.fn(),
|
changeStatus: vi.fn(),
|
||||||
closeAction: vi.fn(),
|
closeAction: vi.fn(),
|
||||||
contactSavingIds: {},
|
contactSavingIds: {},
|
||||||
createForm: { contact: "", targetUserId: "" },
|
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
|
||||||
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
|
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
|
||||||
editForm: { contact: "", targetUserId: "" },
|
editForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingAction: "",
|
loadingAction: "",
|
||||||
@ -118,6 +233,7 @@ function pageFixture(patch = {}) {
|
|||||||
openRateSettings: vi.fn(),
|
openRateSettings: vi.fn(),
|
||||||
openSellerLedger: mocks.openSellerLedger,
|
openSellerLedger: mocks.openSellerLedger,
|
||||||
openStockCredit: vi.fn(),
|
openStockCredit: vi.fn(),
|
||||||
|
openSubSellerList: mocks.openSubSellerList,
|
||||||
page: 1,
|
page: 1,
|
||||||
query: "",
|
query: "",
|
||||||
rateRegionId: "",
|
rateRegionId: "",
|
||||||
@ -132,8 +248,11 @@ function pageFixture(patch = {}) {
|
|||||||
setCreateForm: vi.fn(),
|
setCreateForm: vi.fn(),
|
||||||
setEditForm: vi.fn(),
|
setEditForm: vi.fn(),
|
||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
|
setSubSellerPage: vi.fn(),
|
||||||
setStockForm: vi.fn(),
|
setStockForm: vi.fn(),
|
||||||
status: "",
|
status: "",
|
||||||
|
subSellerData: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
|
subSellerPage: 1,
|
||||||
stockDebitForm: { coinAmount: "", reason: "", rechargeAmount: "" },
|
stockDebitForm: { coinAmount: "", reason: "", rechargeAmount: "" },
|
||||||
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
||||||
sortBy: "",
|
sortBy: "",
|
||||||
@ -142,24 +261,28 @@ function pageFixture(patch = {}) {
|
|||||||
submitEditSeller: vi.fn(),
|
submitEditSeller: vi.fn(),
|
||||||
submitRateSettings: vi.fn(),
|
submitRateSettings: vi.fn(),
|
||||||
submitStockCredit: vi.fn(),
|
submitStockCredit: vi.fn(),
|
||||||
|
toggleSubCoinSellerPermission: mocks.toggleSubCoinSellerPermission,
|
||||||
toggleSeller: vi.fn(),
|
toggleSeller: vi.fn(),
|
||||||
updateRateTier: vi.fn(),
|
updateRateTier: vi.fn(),
|
||||||
...patch,
|
...patch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sellerFixture() {
|
function sellerFixture(patch = {}) {
|
||||||
return {
|
return {
|
||||||
avatar: "",
|
avatar: "",
|
||||||
|
canManageSubCoinSellers: true,
|
||||||
contact: "菲律賓 +63",
|
contact: "菲律賓 +63",
|
||||||
displayUserId: "164425",
|
displayUserId: "164425",
|
||||||
merchantAssetType: "COIN_SELLER_COIN",
|
merchantAssetType: "COIN_SELLER_COIN",
|
||||||
merchantBalance: 9400000,
|
merchantBalance: 9400000,
|
||||||
regionId: 1,
|
regionId: 1,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
subCoinSellerCount: 2,
|
||||||
totalRechargeUsdtMicro: 12500000,
|
totalRechargeUsdtMicro: 12500000,
|
||||||
updatedAtMs: 1760000000000,
|
updatedAtMs: 1760000000000,
|
||||||
userId: "3001",
|
userId: "3001",
|
||||||
username: "Flower92",
|
username: "Flower92",
|
||||||
|
...patch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,7 +108,11 @@ export function HostCountriesPage() {
|
|||||||
|
|
||||||
<HostOrgActionModal
|
<HostOrgActionModal
|
||||||
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||||
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
|
loading={
|
||||||
|
page.loadingAction === "country-create" ||
|
||||||
|
page.loadingAction === "country-edit" ||
|
||||||
|
page.loadingAction === "country-rename"
|
||||||
|
}
|
||||||
open={page.activeAction === "create" || page.activeAction === "edit"}
|
open={page.activeAction === "create" || page.activeAction === "edit"}
|
||||||
sectionTitle="国家信息"
|
sectionTitle="国家信息"
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
@ -116,7 +120,7 @@ export function HostCountriesPage() {
|
|||||||
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
|
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={page.activeAction === "edit" || createDisabled}
|
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
|
||||||
label="国家码"
|
label="国家码"
|
||||||
required
|
required
|
||||||
value={page.countryForm.countryCode}
|
value={page.countryForm.countryCode}
|
||||||
|
|||||||
94
src/features/host-org/pages/HostCountriesPage.test.jsx
Normal file
94
src/features/host-org/pages/HostCountriesPage.test.jsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { HostCountriesPage } from "./HostCountriesPage.jsx";
|
||||||
|
import { useHostCountriesPage } from "@/features/host-org/hooks/useHostCountriesPage.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
setCountryForm: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/hooks/useHostCountriesPage.js", () => ({
|
||||||
|
useHostCountriesPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country code field is editable when editing a country with update permission", () => {
|
||||||
|
vi.mocked(useHostCountriesPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
|
render(<HostCountriesPage />);
|
||||||
|
|
||||||
|
const countryCodeInput = screen.getByRole("textbox", { name: /国家码/ });
|
||||||
|
expect(countryCodeInput).not.toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(countryCodeInput, { target: { value: "PHL" } });
|
||||||
|
|
||||||
|
expect(mocks.setCountryForm).toHaveBeenCalledWith(expect.objectContaining({ countryCode: "PHL" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country code field stays disabled when editing without update permission", () => {
|
||||||
|
vi.mocked(useHostCountriesPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canUpdate: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCountriesPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("textbox", { name: /国家码/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: {
|
||||||
|
canCreate: true,
|
||||||
|
canStatus: true,
|
||||||
|
canUpdate: true,
|
||||||
|
canView: true,
|
||||||
|
},
|
||||||
|
activeAction: "edit",
|
||||||
|
changeEnabledStatus: vi.fn(),
|
||||||
|
changeQuery: vi.fn(),
|
||||||
|
closeAction: vi.fn(),
|
||||||
|
countryForm: countryFormFixture(),
|
||||||
|
data: { items: [], total: 0 },
|
||||||
|
editingCountry: countryFixture(),
|
||||||
|
enabledStatus: "",
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
openCreateCountry: vi.fn(),
|
||||||
|
openEditCountry: vi.fn(),
|
||||||
|
query: "",
|
||||||
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
setCountryForm: mocks.setCountryForm,
|
||||||
|
submitCountry: vi.fn(),
|
||||||
|
toggleCountry: vi.fn(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFormFixture() {
|
||||||
|
return {
|
||||||
|
countryCode: "PH",
|
||||||
|
countryDisplayName: "菲律宾",
|
||||||
|
countryName: "Philippines",
|
||||||
|
enabled: true,
|
||||||
|
flag: "🇵🇭",
|
||||||
|
isoAlpha3: "PHL",
|
||||||
|
isoNumeric: "608",
|
||||||
|
phoneCountryCode: "+63",
|
||||||
|
sortOrder: 1770,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFixture() {
|
||||||
|
return {
|
||||||
|
...countryFormFixture(),
|
||||||
|
countryId: 12,
|
||||||
|
updatedAtMs: 1783578249000,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ const managerPermissionOptions = [
|
|||||||
{ key: "canGrantVip", label: "VIP" },
|
{ key: "canGrantVip", label: "VIP" },
|
||||||
{ key: "canUpdateUserLevel", label: "升级等级" },
|
{ key: "canUpdateUserLevel", label: "升级等级" },
|
||||||
{ key: "canAddBdLeader", label: "BD Leader" },
|
{ key: "canAddBdLeader", label: "BD Leader" },
|
||||||
|
{ key: "canAddBdLeaderInRegion", label: "区域邀请 BD Leader", regionExpansionOnly: true },
|
||||||
{ key: "canAddAdmin", label: "Admin" },
|
{ key: "canAddAdmin", label: "Admin" },
|
||||||
{ key: "canAddSuperadmin", label: "Superadmin" },
|
{ key: "canAddSuperadmin", label: "Superadmin" },
|
||||||
{ key: "canBlockUser", label: "Block User" },
|
{ key: "canBlockUser", label: "Block User" },
|
||||||
@ -201,8 +202,16 @@ function ManagerStatusSwitch({ item, page }) {
|
|||||||
function ManagerPermissions({ item }) {
|
function ManagerPermissions({ item }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.permissionList}>
|
<div className={styles.permissionList}>
|
||||||
{managerPermissionOptions.map((permission) => {
|
{managerPermissionOptions
|
||||||
const enabled = item?.[permission.key] !== false;
|
.filter(
|
||||||
|
(permission) =>
|
||||||
|
!permission.regionExpansionOnly || item?.bdLeaderRegionExpansionConfigurable === true,
|
||||||
|
)
|
||||||
|
.map((permission) => {
|
||||||
|
const enabled =
|
||||||
|
permission.key === "canAddBdLeaderInRegion"
|
||||||
|
? item?.[permission.key] === true
|
||||||
|
: item?.[permission.key] !== false;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
@ -237,11 +246,20 @@ function ManagerActions({ item, page }) {
|
|||||||
function PermissionSwitchGrid({ disabled, form, onChange }) {
|
function PermissionSwitchGrid({ disabled, form, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.permissionGrid}>
|
<div className={styles.permissionGrid}>
|
||||||
{managerPermissionOptions.map((permission) => (
|
{managerPermissionOptions
|
||||||
|
.filter(
|
||||||
|
(permission) =>
|
||||||
|
!permission.regionExpansionOnly || form?.bdLeaderRegionExpansionConfigurable === true,
|
||||||
|
)
|
||||||
|
.map((permission) => (
|
||||||
<label className={styles.permissionSwitch} key={permission.key}>
|
<label className={styles.permissionSwitch} key={permission.key}>
|
||||||
<span>{permission.label}</span>
|
<span>{permission.label}</span>
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={form?.[permission.key] !== false}
|
checked={
|
||||||
|
permission.key === "canAddBdLeaderInRegion"
|
||||||
|
? form?.[permission.key] === true
|
||||||
|
: form?.[permission.key] !== false
|
||||||
|
}
|
||||||
checkedLabel="开"
|
checkedLabel="开"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label={permission.label}
|
label={permission.label}
|
||||||
|
|||||||
@ -17,7 +17,9 @@ afterEach(() => {
|
|||||||
|
|
||||||
test("manager status column uses a switch and toggles through the page hook", () => {
|
test("manager status column uses a switch and toggles through the page hook", () => {
|
||||||
const manager = managerFixture();
|
const manager = managerFixture();
|
||||||
vi.mocked(useHostManagersPage).mockReturnValue(pageFixture({ data: { items: [manager], page: 1, pageSize: 50, total: 1 } }));
|
vi.mocked(useHostManagersPage).mockReturnValue(
|
||||||
|
pageFixture({ data: { items: [manager], page: 1, pageSize: 50, total: 1 } }),
|
||||||
|
);
|
||||||
|
|
||||||
render(<HostManagersPage />);
|
render(<HostManagersPage />);
|
||||||
|
|
||||||
@ -31,6 +33,35 @@ test("manager status column uses a switch and toggles through the page hook", ()
|
|||||||
expect(mocks.toggleManager).toHaveBeenCalledWith(manager, false);
|
expect(mocks.toggleManager).toHaveBeenCalledWith(manager, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("manager form only shows the region invitation switch when backend policy allows configuration", () => {
|
||||||
|
vi.mocked(useHostManagersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
activeAction: "manager",
|
||||||
|
managerForm: {
|
||||||
|
...pageFixture().managerForm,
|
||||||
|
bdLeaderRegionExpansionConfigurable: true,
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rerender } = render(<HostManagersPage />);
|
||||||
|
expect(screen.getByRole("switch", { name: "区域邀请 BD Leader" })).not.toBeChecked();
|
||||||
|
|
||||||
|
vi.mocked(useHostManagersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
activeAction: "manager",
|
||||||
|
managerForm: {
|
||||||
|
...pageFixture().managerForm,
|
||||||
|
bdLeaderRegionExpansionConfigurable: false,
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
rerender(<HostManagersPage />);
|
||||||
|
expect(screen.queryByRole("switch", { name: "区域邀请 BD Leader" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
function pageFixture(patch = {}) {
|
||||||
return {
|
return {
|
||||||
abilities: {
|
abilities: {
|
||||||
@ -50,8 +81,10 @@ function pageFixture(patch = {}) {
|
|||||||
loadingAction: "",
|
loadingAction: "",
|
||||||
loadingRegions: false,
|
loadingRegions: false,
|
||||||
managerForm: {
|
managerForm: {
|
||||||
|
bdLeaderRegionExpansionConfigurable: true,
|
||||||
canAddAdmin: true,
|
canAddAdmin: true,
|
||||||
canAddBdLeader: true,
|
canAddBdLeader: true,
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
canAddSuperadmin: true,
|
canAddSuperadmin: true,
|
||||||
canBlockUser: true,
|
canBlockUser: true,
|
||||||
canGrantAvatarFrame: true,
|
canGrantAvatarFrame: true,
|
||||||
@ -86,6 +119,8 @@ function managerFixture() {
|
|||||||
bdLeaderCount: 3,
|
bdLeaderCount: 3,
|
||||||
canAddAdmin: true,
|
canAddAdmin: true,
|
||||||
canAddBdLeader: false,
|
canAddBdLeader: false,
|
||||||
|
canAddBdLeaderInRegion: false,
|
||||||
|
bdLeaderRegionExpansionConfigurable: true,
|
||||||
canAddSuperadmin: true,
|
canAddSuperadmin: true,
|
||||||
canBlockUser: true,
|
canBlockUser: true,
|
||||||
canGrantAvatarFrame: true,
|
canGrantAvatarFrame: true,
|
||||||
|
|||||||
@ -0,0 +1,205 @@
|
|||||||
|
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||||
|
import HighlightOffOutlined from "@mui/icons-material/HighlightOffOutlined";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import {
|
||||||
|
coinSellerSubApplicationStatusFilters,
|
||||||
|
coinSellerSubApplicationStatusLabels,
|
||||||
|
} from "@/features/host-org/constants.js";
|
||||||
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
|
import { useHostSubCoinSellerApplicationsPage } from "@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js";
|
||||||
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "parent",
|
||||||
|
label: "父币商",
|
||||||
|
render: (item) => <ApplicationUserIdentity user={item.parent} />,
|
||||||
|
width: "minmax(220px, 1.2fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
label: "申请子币商",
|
||||||
|
render: (item) => <ApplicationUserIdentity user={item.target} />,
|
||||||
|
width: "minmax(220px, 1.2fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => <HostOrgStatus value={item.status} />,
|
||||||
|
width: "minmax(120px, 0.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "申请时间",
|
||||||
|
render: (item) => formatMillis(item.createdAtMs),
|
||||||
|
width: "minmax(170px, 0.9fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewedAtMs",
|
||||||
|
label: "审核时间",
|
||||||
|
render: (item) => (Number(item.reviewedAtMs || 0) > 0 ? formatMillis(item.reviewedAtMs) : "-"),
|
||||||
|
width: "minmax(170px, 0.9fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewedByAdminId",
|
||||||
|
label: "审核人",
|
||||||
|
render: (item) => item.reviewedByAdminId || "-",
|
||||||
|
width: "minmax(120px, 0.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reviewReason",
|
||||||
|
label: "审核备注",
|
||||||
|
render: (item) => item.reviewReason || "-",
|
||||||
|
width: "minmax(180px, 1fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
render: (item, _index, context) => <ApplicationActions item={item} page={context?.page} />,
|
||||||
|
width: "minmax(120px, 0.7fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HostSubCoinSellerApplicationsPage() {
|
||||||
|
const page = useHostSubCoinSellerApplicationsPage();
|
||||||
|
const items = page.data.items || [];
|
||||||
|
const total = page.data.total || 0;
|
||||||
|
const reviewTitle = page.reviewDecision === "approved" ? "通过子币商申请" : "拒绝子币商申请";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.root}>
|
||||||
|
<div className={styles.contentPanel}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<section className={styles.filters}>
|
||||||
|
<TextField
|
||||||
|
label="搜索"
|
||||||
|
size="small"
|
||||||
|
value={page.keyword}
|
||||||
|
onChange={(event) => page.changeKeyword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="状态"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={page.status}
|
||||||
|
onChange={(event) => page.changeStatus(event.target.value)}
|
||||||
|
>
|
||||||
|
{coinSellerSubApplicationStatusFilters.map(([value, label]) => (
|
||||||
|
<MenuItem key={value || "all"} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="父币商ID"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={page.parentUserId}
|
||||||
|
onChange={(event) => page.changeParentUserId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="目标用户ID"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={page.targetUserId}
|
||||||
|
onChange={(event) => page.changeTargetUserId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="text" onClick={page.resetFilters}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataState error={page.error} loading={page.loading}>
|
||||||
|
<div className={styles.listBlock}>
|
||||||
|
<HostOrgTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ page }}
|
||||||
|
items={items}
|
||||||
|
minWidth="1280px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.applicationId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HostOrgActionModal
|
||||||
|
disabled={!page.abilities.canAudit}
|
||||||
|
loading={String(page.loadingAction).startsWith("review-")}
|
||||||
|
open={page.activeAction === "review"}
|
||||||
|
sectionTitle="审核信息"
|
||||||
|
submitLabel={page.reviewDecision === "approved" ? "确认通过" : "确认拒绝"}
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitReview}
|
||||||
|
title={reviewTitle}
|
||||||
|
>
|
||||||
|
<TextField disabled label="申请状态" value={statusText(page.selectedApplication?.status)} />
|
||||||
|
<TextField disabled label="Command ID" value={page.reviewForm.commandId} />
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canAudit}
|
||||||
|
label="审核备注"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={page.reviewForm.reason}
|
||||||
|
onChange={(event) => page.setReviewForm({ ...page.reviewForm, reason: event.target.value })}
|
||||||
|
/>
|
||||||
|
</HostOrgActionModal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicationUserIdentity({ user }) {
|
||||||
|
return (
|
||||||
|
<AdminUserIdentity
|
||||||
|
avatar={user?.avatar}
|
||||||
|
displayUserId={user?.displayUserId}
|
||||||
|
name={user?.username}
|
||||||
|
rows={[user?.userId, user?.regionName].filter(Boolean)}
|
||||||
|
userId={user?.userId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicationActions({ item, page }) {
|
||||||
|
if (!page?.abilities.canAudit || item.status !== "pending") {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
const loading = page.loadingAction === `review-${item.applicationId}`;
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={loading}
|
||||||
|
label="通过"
|
||||||
|
primary
|
||||||
|
onClick={() => page.openReview(item, "approved")}
|
||||||
|
>
|
||||||
|
<CheckCircleOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton disabled={loading} label="拒绝" onClick={() => page.openReview(item, "rejected")}>
|
||||||
|
<HighlightOffOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status) {
|
||||||
|
return coinSellerSubApplicationStatusLabels[status] || status || "-";
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { HostSubCoinSellerApplicationsPage } from "./HostSubCoinSellerApplicationsPage.jsx";
|
||||||
|
import { useHostSubCoinSellerApplicationsPage } from "@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
openReview: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js", () => ({
|
||||||
|
useHostSubCoinSellerApplicationsPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sub coin seller application page renders pending applications with audit actions", () => {
|
||||||
|
const application = applicationFixture();
|
||||||
|
vi.mocked(useHostSubCoinSellerApplicationsPage).mockReturnValue(pageFixture({ data: pageData([application]) }));
|
||||||
|
|
||||||
|
render(<HostSubCoinSellerApplicationsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("父币商")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("申请子币商")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Parent seller")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Target user")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByLabelText("通过").find((node) => node.tagName.toLowerCase() === "button"));
|
||||||
|
fireEvent.click(screen.getAllByLabelText("拒绝").find((node) => node.tagName.toLowerCase() === "button"));
|
||||||
|
|
||||||
|
expect(mocks.openReview).toHaveBeenCalledWith(application, "approved");
|
||||||
|
expect(mocks.openReview).toHaveBeenCalledWith(application, "rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sub coin seller application page hides audit actions for reviewed rows", () => {
|
||||||
|
vi.mocked(useHostSubCoinSellerApplicationsPage).mockReturnValue(
|
||||||
|
pageFixture({ data: pageData([applicationFixture({ status: "approved" })]) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostSubCoinSellerApplicationsPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText("通过")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("拒绝")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: { canAudit: true, canView: true },
|
||||||
|
activeAction: "",
|
||||||
|
changeKeyword: vi.fn(),
|
||||||
|
changeParentUserId: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
changeTargetUserId: vi.fn(),
|
||||||
|
closeAction: vi.fn(),
|
||||||
|
data: pageData([applicationFixture()]),
|
||||||
|
error: null,
|
||||||
|
keyword: "",
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
openReview: mocks.openReview,
|
||||||
|
page: 1,
|
||||||
|
parentUserId: "",
|
||||||
|
resetFilters: mocks.resetFilters,
|
||||||
|
reviewDecision: "approved",
|
||||||
|
reviewForm: { commandId: "coin-seller-sub-approved-test", reason: "" },
|
||||||
|
selectedApplication: null,
|
||||||
|
setPage: vi.fn(),
|
||||||
|
setReviewForm: vi.fn(),
|
||||||
|
status: "pending",
|
||||||
|
submitReview: vi.fn((event) => event.preventDefault()),
|
||||||
|
targetUserId: "",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageData(items) {
|
||||||
|
return { items, page: 1, pageSize: 50, total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
applicationId: "sub-app-1",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
parent: {
|
||||||
|
avatar: "",
|
||||||
|
displayUserId: "163001",
|
||||||
|
regionName: "中东区",
|
||||||
|
userId: "3001",
|
||||||
|
username: "Parent seller",
|
||||||
|
},
|
||||||
|
parentUserId: "3001",
|
||||||
|
relationId: "",
|
||||||
|
reviewReason: "",
|
||||||
|
reviewedAtMs: 0,
|
||||||
|
reviewedByAdminId: "",
|
||||||
|
status: "pending",
|
||||||
|
target: {
|
||||||
|
avatar: "",
|
||||||
|
displayUserId: "168933",
|
||||||
|
regionName: "中东区",
|
||||||
|
userId: "3002",
|
||||||
|
username: "Target user",
|
||||||
|
},
|
||||||
|
targetUserId: "3002",
|
||||||
|
updatedAtMs: 1760000000000,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -68,6 +68,15 @@ export function useCoinSellerAbilities() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCoinSellerSubApplicationAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canAudit: can(PERMISSIONS.coinSellerSubApplicationAudit),
|
||||||
|
canView: can(PERMISSIONS.coinSellerSubApplicationView),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useHostWithdrawalAbilities() {
|
export function useHostWithdrawalAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,17 @@ export const hostOrgRoutes = [
|
|||||||
path: "/host/coin-sellers",
|
path: "/host/coin-sellers",
|
||||||
permission: PERMISSIONS.coinSellerView,
|
permission: PERMISSIONS.coinSellerView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Sub Coin Seller 申请列表",
|
||||||
|
loader: () =>
|
||||||
|
import("./pages/HostSubCoinSellerApplicationsPage.jsx").then(
|
||||||
|
(module) => module.HostSubCoinSellerApplicationsPage,
|
||||||
|
),
|
||||||
|
menuCode: MENU_CODES.hostOrgCoinSellerSubApplications,
|
||||||
|
pageKey: "host-org-coin-seller-sub-applications",
|
||||||
|
path: "/host/coin-seller-sub-applications",
|
||||||
|
permission: PERMISSIONS.coinSellerSubApplicationView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "主播提现",
|
label: "主播提现",
|
||||||
loader: () => import("./pages/HostWithdrawalsPage.jsx").then((module) => module.HostWithdrawalsPage),
|
loader: () => import("./pages/HostWithdrawalsPage.jsx").then((module) => module.HostWithdrawalsPage),
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema, createManagerSchema } from "./schema";
|
import {
|
||||||
|
agencyCloseSchema,
|
||||||
|
agencyJoinEnabledSchema,
|
||||||
|
coinSellerStatusSchema,
|
||||||
|
coinSellerSubApplicationReviewSchema,
|
||||||
|
createCoinSellerSchema,
|
||||||
|
createManagerSchema,
|
||||||
|
countryRenameCodeSchema,
|
||||||
|
countryUpdateSchema,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
test("coin seller contact accepts plus sign with large internal user id", () => {
|
test("coin seller contact accepts plus sign with large internal user id", () => {
|
||||||
const result = coinSellerStatusSchema.safeParse({
|
const result = coinSellerStatusSchema.safeParse({
|
||||||
@ -17,6 +26,50 @@ test("coin seller contact accepts plus sign with large internal user id", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin seller schemas preserve sub seller permission defaults and explicit false", () => {
|
||||||
|
const createDefault = createCoinSellerSchema.safeParse({
|
||||||
|
commandId: "coin-seller-create-test",
|
||||||
|
targetUserId: "1003",
|
||||||
|
});
|
||||||
|
const statusDefault = coinSellerStatusSchema.safeParse({
|
||||||
|
commandId: "coin-seller-edit-test",
|
||||||
|
status: "active",
|
||||||
|
targetUserId: "320756743338463232",
|
||||||
|
});
|
||||||
|
const statusDisabled = coinSellerStatusSchema.safeParse({
|
||||||
|
canManageSubCoinSellers: false,
|
||||||
|
commandId: "coin-seller-edit-test",
|
||||||
|
status: "active",
|
||||||
|
targetUserId: "320756743338463232",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createDefault.success).toBe(true);
|
||||||
|
expect(statusDefault.success).toBe(true);
|
||||||
|
expect(statusDisabled.success).toBe(true);
|
||||||
|
if (createDefault.success) {
|
||||||
|
expect(createDefault.data.canManageSubCoinSellers).toBe(true);
|
||||||
|
}
|
||||||
|
if (statusDefault.success) {
|
||||||
|
expect(statusDefault.data).not.toHaveProperty("canManageSubCoinSellers");
|
||||||
|
}
|
||||||
|
if (statusDisabled.success) {
|
||||||
|
expect(statusDisabled.data.canManageSubCoinSellers).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller sub application review schema keeps command id and reason", () => {
|
||||||
|
const result = coinSellerSubApplicationReviewSchema.safeParse({
|
||||||
|
commandId: "coin-seller-sub-application-review-test",
|
||||||
|
reason: "same region and profile is active",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.commandId).toBe("coin-seller-sub-application-review-test");
|
||||||
|
expect(result.data.reason).toBe("same region and profile is active");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("agency action schemas preserve large agency ids as strings", () => {
|
test("agency action schemas preserve large agency ids as strings", () => {
|
||||||
const agencyId = "321170072154411009";
|
const agencyId = "321170072154411009";
|
||||||
const closeResult = agencyCloseSchema.safeParse({
|
const closeResult = agencyCloseSchema.safeParse({
|
||||||
@ -41,6 +94,36 @@ test("agency action schemas preserve large agency ids as strings", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("country update schema excludes country code from metadata update payload", () => {
|
||||||
|
const result = countryUpdateSchema.safeParse({
|
||||||
|
countryCode: "phl",
|
||||||
|
countryDisplayName: "Philippines Display",
|
||||||
|
countryName: "Philippines",
|
||||||
|
sortOrder: "1770",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data).not.toHaveProperty("countryCode");
|
||||||
|
expect(result.data).not.toHaveProperty("enabled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country rename schema keeps editable country code", () => {
|
||||||
|
const result = countryRenameCodeSchema.safeParse({
|
||||||
|
countryCode: "phl",
|
||||||
|
countryDisplayName: "Philippines Display",
|
||||||
|
countryName: "Philippines",
|
||||||
|
sortOrder: "1770",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.countryCode).toBe("PHL");
|
||||||
|
expect(result.data).not.toHaveProperty("enabled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("manager schema defaults vip grant permission on and preserves explicit false", () => {
|
test("manager schema defaults vip grant permission on and preserves explicit false", () => {
|
||||||
const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" });
|
const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" });
|
||||||
const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" });
|
const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" });
|
||||||
@ -49,6 +132,7 @@ test("manager schema defaults vip grant permission on and preserves explicit fal
|
|||||||
expect(disabledResult.success).toBe(true);
|
expect(disabledResult.success).toBe(true);
|
||||||
if (defaultResult.success) {
|
if (defaultResult.success) {
|
||||||
expect(defaultResult.data.canGrantVip).toBe(true);
|
expect(defaultResult.data.canGrantVip).toBe(true);
|
||||||
|
expect(defaultResult.data.canAddBdLeaderInRegion).toBe(false);
|
||||||
}
|
}
|
||||||
if (disabledResult.success) {
|
if (disabledResult.success) {
|
||||||
expect(disabledResult.data.canGrantVip).toBe(false);
|
expect(disabledResult.data.canGrantVip).toBe(false);
|
||||||
|
|||||||
@ -105,12 +105,14 @@ export const bdStatusSchema = commandBaseSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const createCoinSellerSchema = commandContactSchema.extend({
|
export const createCoinSellerSchema = commandContactSchema.extend({
|
||||||
|
canManageSubCoinSellers: z.boolean().optional().default(true),
|
||||||
targetUserId: userIdSchema,
|
targetUserId: userIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createManagerSchema = z.object({
|
export const createManagerSchema = z.object({
|
||||||
canAddAdmin: z.boolean().optional().default(true),
|
canAddAdmin: z.boolean().optional().default(true),
|
||||||
canAddBdLeader: z.boolean().optional().default(true),
|
canAddBdLeader: z.boolean().optional().default(true),
|
||||||
|
canAddBdLeaderInRegion: z.boolean().optional().default(false),
|
||||||
canAddSuperadmin: z.boolean().optional().default(true),
|
canAddSuperadmin: z.boolean().optional().default(true),
|
||||||
canBlockUser: z.boolean().optional().default(true),
|
canBlockUser: z.boolean().optional().default(true),
|
||||||
canGrantAvatarFrame: z.boolean().optional().default(true),
|
canGrantAvatarFrame: z.boolean().optional().default(true),
|
||||||
@ -124,10 +126,13 @@ export const createManagerSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||||
|
canManageSubCoinSellers: z.boolean().optional(),
|
||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
targetUserId: entityIdSchema,
|
targetUserId: entityIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const coinSellerSubApplicationReviewSchema = commandBaseSchema;
|
||||||
|
|
||||||
export const coinSellerStockCreditSchema = z
|
export const coinSellerStockCreditSchema = z
|
||||||
.object({
|
.object({
|
||||||
coinAmount: z.coerce.number().int().positive("请输入充值金币"),
|
coinAmount: z.coerce.number().int().positive("请输入充值金币"),
|
||||||
@ -200,6 +205,10 @@ export const countryUpdateSchema = countryCreateSchema.omit({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const countryRenameCodeSchema = countryCreateSchema.omit({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const regionCreateSchema = regionBaseSchema;
|
export const regionCreateSchema = regionBaseSchema;
|
||||||
|
|
||||||
export const regionUpdateSchema = regionBaseSchema;
|
export const regionUpdateSchema = regionBaseSchema;
|
||||||
@ -213,6 +222,7 @@ export type CreateBDForm = z.infer<typeof createBDSchema>;
|
|||||||
export type BDStatusForm = z.infer<typeof bdStatusSchema>;
|
export type BDStatusForm = z.infer<typeof bdStatusSchema>;
|
||||||
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||||
|
export type CoinSellerSubApplicationReviewForm = z.infer<typeof coinSellerSubApplicationReviewSchema>;
|
||||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||||
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
||||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||||
@ -221,6 +231,7 @@ export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
|||||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||||
export type CountryCreateForm = z.infer<typeof countryCreateSchema>;
|
export type CountryCreateForm = z.infer<typeof countryCreateSchema>;
|
||||||
export type CountryUpdateForm = z.infer<typeof countryUpdateSchema>;
|
export type CountryUpdateForm = z.infer<typeof countryUpdateSchema>;
|
||||||
|
export type CountryRenameCodeForm = z.infer<typeof countryRenameCodeSchema>;
|
||||||
export type RegionCreateForm = z.infer<typeof regionCreateSchema>;
|
export type RegionCreateForm = z.infer<typeof regionCreateSchema>;
|
||||||
export type RegionUpdateForm = z.infer<typeof regionUpdateSchema>;
|
export type RegionUpdateForm = z.infer<typeof regionUpdateSchema>;
|
||||||
export type RegionCountriesForm = z.infer<typeof regionCountriesSchema>;
|
export type RegionCountriesForm = z.infer<typeof regionCountriesSchema>;
|
||||||
|
|||||||
@ -119,7 +119,7 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
await listCoinSellerLedger({
|
await listCoinSellerLedger({
|
||||||
ledger_type: "seller_transfer",
|
ledger_type: "sub_seller_transfer",
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
seller_user_id: "3001",
|
seller_user_id: "3001",
|
||||||
@ -129,7 +129,7 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
|
|||||||
|
|
||||||
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger?");
|
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger?");
|
||||||
expect(String(listUrl)).toContain("seller_user_id=3001");
|
expect(String(listUrl)).toContain("seller_user_id=3001");
|
||||||
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
|
expect(String(listUrl)).toContain("ledger_type=sub_seller_transfer");
|
||||||
expect(listInit?.method).toBe("GET");
|
expect(listInit?.method).toBe("GET");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -141,8 +141,9 @@ export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<Coi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function exportCoinSellerLedger(query: PageQuery = {}): Promise<Response> {
|
export function exportCoinSellerLedger(query: PageQuery = {}): Promise<Response> {
|
||||||
return apiRequest("/v1/admin/operations/coin-seller-ledger/export", {
|
const endpoint = API_ENDPOINTS.exportCoinSellerLedger;
|
||||||
method: "GET",
|
return apiRequest(apiEndpointPath(API_OPERATIONS.exportCoinSellerLedger), {
|
||||||
|
method: endpoint.method,
|
||||||
query,
|
query,
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const pageSize = 50;
|
|||||||
const ledgerTypeOptions = [
|
const ledgerTypeOptions = [
|
||||||
{ label: "后台入账", value: "admin_stock_credit" },
|
{ label: "后台入账", value: "admin_stock_credit" },
|
||||||
{ label: "币商转用户", value: "seller_transfer" },
|
{ label: "币商转用户", value: "seller_transfer" },
|
||||||
|
{ label: "向子币商转账", value: "sub_seller_transfer" },
|
||||||
{ label: "工资转币商", value: "salary_transfer_received" },
|
{ label: "工资转币商", value: "salary_transfer_received" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -27,12 +28,14 @@ const ledgerTypeLabels = {
|
|||||||
admin_stock_credit: "后台入账",
|
admin_stock_credit: "后台入账",
|
||||||
salary_transfer_received: "工资转币商",
|
salary_transfer_received: "工资转币商",
|
||||||
seller_transfer: "币商转用户",
|
seller_transfer: "币商转用户",
|
||||||
|
sub_seller_transfer: "向子币商转账",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bizTypeLabels = {
|
const bizTypeLabels = {
|
||||||
coin_seller_coin_compensation: "金币补偿",
|
coin_seller_coin_compensation: "金币补偿",
|
||||||
coin_seller_stock_deduction: "USDT扣除",
|
coin_seller_stock_deduction: "USDT扣除",
|
||||||
coin_seller_stock_purchase: "USDT进货",
|
coin_seller_stock_purchase: "USDT进货",
|
||||||
|
coin_seller_sub_transfer: "向子币商转账",
|
||||||
coin_seller_transfer: "币商转用户",
|
coin_seller_transfer: "币商转用户",
|
||||||
manual_credit: "金币增加",
|
manual_credit: "金币增加",
|
||||||
salary_transfer_to_coin_seller: "工资转币商",
|
salary_transfer_to_coin_seller: "工资转币商",
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
|
||||||
|
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
|
||||||
|
usePaginatedQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||||
|
useToast: () => ({ showToast: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller ledger shows sub seller transfer type and filter option", () => {
|
||||||
|
vi.mocked(usePaginatedQuery).mockReturnValue(queryFixture([subSellerTransferEntry()]));
|
||||||
|
|
||||||
|
render(<CoinSellerLedgerTable />);
|
||||||
|
|
||||||
|
expect(screen.getByText("向子币商转账")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Parent seller")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Child seller")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("-80")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: /流水类型/ })[0]);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("向子币商转账").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryFixture(items) {
|
||||||
|
return {
|
||||||
|
data: { items, page: 1, pageSize: 50, total: items.length },
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
reload: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subSellerTransferEntry() {
|
||||||
|
return {
|
||||||
|
amount: 80,
|
||||||
|
availableDelta: -80,
|
||||||
|
bizType: "coin_seller_sub_transfer",
|
||||||
|
commandId: "cmd-sub-transfer",
|
||||||
|
counterpartyUserId: "5001",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
direction: "expense",
|
||||||
|
entryId: 991,
|
||||||
|
ledgerType: "sub_seller_transfer",
|
||||||
|
metadata: { child_user_id: 5001, parent_user_id: 3001 },
|
||||||
|
receiver: { displayUserId: "5001", userId: "5001", username: "Child seller" },
|
||||||
|
seller: { displayUserId: "3001", userId: "3001", username: "Parent seller" },
|
||||||
|
sellerBalanceAfter: 920,
|
||||||
|
transactionId: "tx-sub-transfer",
|
||||||
|
};
|
||||||
|
}
|
||||||
310
src/features/policy-config/api.test.ts
Normal file
310
src/features/policy-config/api.test.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||||
|
import { createPolicyInstance, listPolicyInstances, listPolicyTemplates, publishPolicyInstance, savePolicyTemplate, updatePolicyInstanceStatus } from "./api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAccessToken("");
|
||||||
|
setSelectedAppCode("lalu");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockEnvelope(data: unknown, status = 200) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data }), { status })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHeaders(init: RequestInit | undefined): Record<string, string> {
|
||||||
|
return (init?.headers || {}) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBody(init: RequestInit | undefined): Record<string, unknown> {
|
||||||
|
return JSON.parse(String(init?.body || "{}")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("listPolicyTemplates normalizes snake case page and template fields", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
created_at_ms: "1767225600000",
|
||||||
|
description: "Huwaa default policy",
|
||||||
|
name: "Huwaa Google + coin seller",
|
||||||
|
rule_json: "{\"google_coin_per_usd\":70000,\"coin_seller\":{\"levels\":[{\"coin_per_usd\":92000}]}}",
|
||||||
|
status: "active",
|
||||||
|
template_code: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
template_id: "101",
|
||||||
|
template_version: "v1",
|
||||||
|
updated_at_ms: "1767229200000",
|
||||||
|
version_id: "202",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listPolicyTemplates({ page: 1, page_size: 50, status: "active" });
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-templates?");
|
||||||
|
expect(String(url)).toContain("page_size=50");
|
||||||
|
expect(String(url)).toContain("status=active");
|
||||||
|
expect(init?.method).toBe("GET");
|
||||||
|
expect(result.pageSize).toBe(50);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
createdAtMs: 1767225600000,
|
||||||
|
ruleJson: {
|
||||||
|
coin_seller: { levels: [{ coin_per_usd: 92000 }] },
|
||||||
|
google_coin_per_usd: 70000,
|
||||||
|
},
|
||||||
|
templateCode: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
templateId: 101,
|
||||||
|
templateVersion: "v1",
|
||||||
|
updatedAtMs: 1767229200000,
|
||||||
|
versionId: 202,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("savePolicyTemplate sends typed upsert payload and normalizes response", async () => {
|
||||||
|
mockEnvelope(
|
||||||
|
{
|
||||||
|
created_at_ms: "1767225600000",
|
||||||
|
description: "desc",
|
||||||
|
name: "Policy",
|
||||||
|
rule_json: { points_per_usd: 100000 },
|
||||||
|
status: "active",
|
||||||
|
template_code: "code",
|
||||||
|
template_id: "101",
|
||||||
|
template_version: "v2",
|
||||||
|
updated_at_ms: "1767229200000",
|
||||||
|
version_id: "202",
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await savePolicyTemplate({
|
||||||
|
description: "desc",
|
||||||
|
name: "Policy",
|
||||||
|
rule_json: { points_per_usd: 100000 },
|
||||||
|
status: "active",
|
||||||
|
template_code: "code",
|
||||||
|
template_version: "v2",
|
||||||
|
});
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-templates");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(requestHeaders(init)["Content-Type"]).toBe("application/json");
|
||||||
|
expect(requestBody(init)).toMatchObject({
|
||||||
|
name: "Policy",
|
||||||
|
rule_json: { points_per_usd: 100000 },
|
||||||
|
template_code: "code",
|
||||||
|
template_version: "v2",
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ruleJson: { points_per_usd: 100000 },
|
||||||
|
templateCode: "code",
|
||||||
|
templateId: 101,
|
||||||
|
templateVersion: "v2",
|
||||||
|
versionId: 202,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listPolicyInstances normalizes snake case page and instance fields", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
app_code: "huwaa",
|
||||||
|
created_at_ms: "1767225600000",
|
||||||
|
effective_from_ms: "1767225600000",
|
||||||
|
effective_to_ms: "1767312000000",
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
instance_id: "301",
|
||||||
|
last_published_at_ms: "1767229200000",
|
||||||
|
publish_error: "",
|
||||||
|
publish_status: "published",
|
||||||
|
region_scope: "region_id:1001",
|
||||||
|
status: "active",
|
||||||
|
template_code: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
template_version: "v1",
|
||||||
|
updated_at_ms: "1767229300000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 2,
|
||||||
|
page_size: 20,
|
||||||
|
total: 31,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listPolicyInstances({ keyword: "huwaa", page: 2, page_size: 20 });
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-instances?");
|
||||||
|
expect(String(url)).toContain("keyword=huwaa");
|
||||||
|
expect(String(url)).toContain("page_size=20");
|
||||||
|
expect(init?.method).toBe("GET");
|
||||||
|
expect(result.pageSize).toBe(20);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
appCode: "huwaa",
|
||||||
|
createdAtMs: 1767225600000,
|
||||||
|
effectiveFromMs: 1767225600000,
|
||||||
|
effectiveToMs: 1767312000000,
|
||||||
|
instanceCode: "huwaa_policy",
|
||||||
|
instanceId: 301,
|
||||||
|
lastPublishedAtMs: 1767229200000,
|
||||||
|
publishStatus: "published",
|
||||||
|
regionScope: "region_id:1001",
|
||||||
|
templateCode: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
templateVersion: "v1",
|
||||||
|
updatedAtMs: 1767229300000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createPolicyInstance sends app-scoped payload with selected X-App-Code", async () => {
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
mockEnvelope(
|
||||||
|
{
|
||||||
|
app_code: "huwaa",
|
||||||
|
created_at_ms: "1767225600000",
|
||||||
|
effective_from_ms: "1767225600000",
|
||||||
|
effective_to_ms: "0",
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
instance_id: "301",
|
||||||
|
last_published_at_ms: "0",
|
||||||
|
publish_error: "",
|
||||||
|
publish_status: "draft",
|
||||||
|
region_scope: "all_active_regions",
|
||||||
|
status: "active",
|
||||||
|
template_code: "code",
|
||||||
|
template_version: "v1",
|
||||||
|
updated_at_ms: "1767225600000",
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await createPolicyInstance({
|
||||||
|
effective_from_ms: 1767225600000,
|
||||||
|
effective_to_ms: 0,
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
region_scope: "all_active_regions",
|
||||||
|
status: "active",
|
||||||
|
template_code: "code",
|
||||||
|
template_version: "v1",
|
||||||
|
});
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-instances");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(requestHeaders(init)["X-App-Code"]).toBe("huwaa");
|
||||||
|
expect(requestBody(init)).toMatchObject({
|
||||||
|
effective_from_ms: 1767225600000,
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
region_scope: "all_active_regions",
|
||||||
|
template_code: "code",
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
appCode: "huwaa",
|
||||||
|
instanceCode: "huwaa_policy",
|
||||||
|
instanceId: 301,
|
||||||
|
regionScope: "all_active_regions",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishPolicyInstance normalizes snake case publish result fields", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
app_code: "huwaa",
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
instance_id: "301",
|
||||||
|
job_id: "401",
|
||||||
|
published_at_ms: "1767229300000",
|
||||||
|
published_region_ids: ["1001", "1002"],
|
||||||
|
status: "published",
|
||||||
|
target_count: "2",
|
||||||
|
template_code: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
template_version: "v1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await publishPolicyInstance("301");
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-instances/301/publish");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
appCode: "huwaa",
|
||||||
|
instanceCode: "huwaa_policy",
|
||||||
|
instanceId: 301,
|
||||||
|
jobId: 401,
|
||||||
|
publishedAtMs: 1767229300000,
|
||||||
|
publishedRegionIds: [1001, 1002],
|
||||||
|
status: "published",
|
||||||
|
targetCount: 2,
|
||||||
|
templateCode: "first_google70000_coin_seller_92000_100000_v1",
|
||||||
|
templateVersion: "v1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updatePolicyInstanceStatus sends PUT status with selected X-App-Code", async () => {
|
||||||
|
setSelectedAppCode("huwaa");
|
||||||
|
mockEnvelope({
|
||||||
|
app_code: "huwaa",
|
||||||
|
created_at_ms: "1767225600000",
|
||||||
|
effective_from_ms: "1767225600000",
|
||||||
|
effective_to_ms: "0",
|
||||||
|
instance_code: "huwaa_policy",
|
||||||
|
instance_id: "301",
|
||||||
|
last_published_at_ms: "0",
|
||||||
|
publish_error: "",
|
||||||
|
publish_status: "draft",
|
||||||
|
region_scope: "all_active_regions",
|
||||||
|
status: "disabled",
|
||||||
|
template_code: "code",
|
||||||
|
template_version: "v1",
|
||||||
|
updated_at_ms: "1767229300000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updatePolicyInstanceStatus(301, "disabled");
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/policy-instances/301/status");
|
||||||
|
expect(init?.method).toBe("PUT");
|
||||||
|
expect(requestHeaders(init)["X-App-Code"]).toBe("huwaa");
|
||||||
|
expect(requestBody(init)).toEqual({ status: "disabled" });
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
appCode: "huwaa",
|
||||||
|
instanceId: 301,
|
||||||
|
status: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
239
src/features/policy-config/api.ts
Normal file
239
src/features/policy-config/api.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export interface PolicyTemplateDto {
|
||||||
|
createdAtMs: number;
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
ruleJson: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
templateCode: string;
|
||||||
|
templateId: number;
|
||||||
|
templateVersion: string;
|
||||||
|
updatedAtMs: number;
|
||||||
|
versionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyInstanceDto {
|
||||||
|
appCode: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
effectiveFromMs: number;
|
||||||
|
effectiveToMs: number;
|
||||||
|
instanceCode: string;
|
||||||
|
instanceId: number;
|
||||||
|
lastPublishedAtMs: number;
|
||||||
|
publishError: string;
|
||||||
|
publishStatus: string;
|
||||||
|
regionScope: string;
|
||||||
|
status: string;
|
||||||
|
templateCode: string;
|
||||||
|
templateVersion: string;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyPublishDto {
|
||||||
|
appCode: string;
|
||||||
|
instanceCode: string;
|
||||||
|
instanceId: number;
|
||||||
|
jobId: number;
|
||||||
|
publishedAtMs: number;
|
||||||
|
publishedRegionIds: number[];
|
||||||
|
status: string;
|
||||||
|
targetCount: number;
|
||||||
|
templateCode: string;
|
||||||
|
templateVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyTemplatePayload {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
rule_json: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
template_code: string;
|
||||||
|
template_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyInstancePayload {
|
||||||
|
effective_from_ms: number;
|
||||||
|
effective_to_ms: number;
|
||||||
|
instance_code: string;
|
||||||
|
region_scope: string;
|
||||||
|
status: string;
|
||||||
|
template_code: string;
|
||||||
|
template_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPolicyTemplates(query: PageQuery = {}): Promise<ApiPage<PolicyTemplateDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listPolicyTemplates;
|
||||||
|
return apiRequest<ApiPage<RawPolicyTemplate>>(apiEndpointPath(API_OPERATIONS.listPolicyTemplates), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then(normalizeTemplatePage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePolicyTemplate(payload: PolicyTemplatePayload): Promise<PolicyTemplateDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.savePolicyTemplate;
|
||||||
|
return apiRequest<RawPolicyTemplate, PolicyTemplatePayload>(apiEndpointPath(API_OPERATIONS.savePolicyTemplate), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPolicyInstances(query: PageQuery = {}): Promise<ApiPage<PolicyInstanceDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listPolicyInstances;
|
||||||
|
return apiRequest<ApiPage<RawPolicyInstance>>(apiEndpointPath(API_OPERATIONS.listPolicyInstances), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then(normalizeInstancePage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPolicyInstance(payload: PolicyInstancePayload): Promise<PolicyInstanceDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createPolicyInstance;
|
||||||
|
return apiRequest<RawPolicyInstance, PolicyInstancePayload>(apiEndpointPath(API_OPERATIONS.createPolicyInstance), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPolicyInstance(instanceId: EntityId): Promise<PolicyPublishDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.publishPolicyInstance;
|
||||||
|
return apiRequest<RawPolicyPublish>(apiEndpointPath(API_OPERATIONS.publishPolicyInstance, { instance_id: instanceId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizePublish);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePolicyInstanceStatus(instanceId: EntityId, status: string): Promise<PolicyInstanceDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updatePolicyInstanceStatus;
|
||||||
|
return apiRequest<RawPolicyInstance, { status: string }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updatePolicyInstanceStatus, { instance_id: instanceId }),
|
||||||
|
{
|
||||||
|
body: { status },
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawPolicyTemplate = PolicyTemplateDto & {
|
||||||
|
created_at_ms?: number | string;
|
||||||
|
rule_json?: unknown;
|
||||||
|
template_code?: string;
|
||||||
|
template_id?: number | string;
|
||||||
|
template_version?: string;
|
||||||
|
updated_at_ms?: number | string;
|
||||||
|
version_id?: number | string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
type RawPolicyInstance = PolicyInstanceDto & {
|
||||||
|
app_code?: string;
|
||||||
|
created_at_ms?: number | string;
|
||||||
|
effective_from_ms?: number | string;
|
||||||
|
effective_to_ms?: number | string;
|
||||||
|
instance_code?: string;
|
||||||
|
instance_id?: number | string;
|
||||||
|
last_published_at_ms?: number | string;
|
||||||
|
publish_error?: string;
|
||||||
|
publish_status?: string;
|
||||||
|
region_scope?: string;
|
||||||
|
template_code?: string;
|
||||||
|
template_version?: string;
|
||||||
|
updated_at_ms?: number | string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
type RawPolicyPublish = PolicyPublishDto & {
|
||||||
|
app_code?: string;
|
||||||
|
instance_code?: string;
|
||||||
|
instance_id?: number | string;
|
||||||
|
job_id?: number | string;
|
||||||
|
published_at_ms?: number | string;
|
||||||
|
published_region_ids?: Array<number | string>;
|
||||||
|
target_count?: number | string;
|
||||||
|
template_code?: string;
|
||||||
|
template_version?: string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
function normalizeTemplatePage(page: ApiPage<RawPolicyTemplate>): ApiPage<PolicyTemplateDto> {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
pageSize: numberValue(page.pageSize ?? (page as unknown as Record<string, unknown>).page_size),
|
||||||
|
items: (page.items || []).map(normalizeTemplate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInstancePage(page: ApiPage<RawPolicyInstance>): ApiPage<PolicyInstanceDto> {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
pageSize: numberValue(page.pageSize ?? (page as unknown as Record<string, unknown>).page_size),
|
||||||
|
items: (page.items || []).map(normalizeInstance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplate(item: RawPolicyTemplate): PolicyTemplateDto {
|
||||||
|
return {
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
description: stringValue(item.description),
|
||||||
|
name: stringValue(item.name),
|
||||||
|
ruleJson: normalizeRuleJson(item.ruleJson ?? item.rule_json),
|
||||||
|
status: stringValue(item.status || "active"),
|
||||||
|
templateCode: stringValue(item.templateCode ?? item.template_code),
|
||||||
|
templateId: numberValue(item.templateId ?? item.template_id),
|
||||||
|
templateVersion: stringValue(item.templateVersion ?? item.template_version),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
versionId: numberValue(item.versionId ?? item.version_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInstance(item: RawPolicyInstance): PolicyInstanceDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms),
|
||||||
|
effectiveToMs: numberValue(item.effectiveToMs ?? item.effective_to_ms),
|
||||||
|
instanceCode: stringValue(item.instanceCode ?? item.instance_code),
|
||||||
|
instanceId: numberValue(item.instanceId ?? item.instance_id),
|
||||||
|
lastPublishedAtMs: numberValue(item.lastPublishedAtMs ?? item.last_published_at_ms),
|
||||||
|
publishError: stringValue(item.publishError ?? item.publish_error),
|
||||||
|
publishStatus: stringValue((item.publishStatus ?? item.publish_status) || "draft"),
|
||||||
|
regionScope: stringValue((item.regionScope ?? item.region_scope) || "all_active_regions"),
|
||||||
|
status: stringValue(item.status || "active"),
|
||||||
|
templateCode: stringValue(item.templateCode ?? item.template_code),
|
||||||
|
templateVersion: stringValue(item.templateVersion ?? item.template_version),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublish(item: RawPolicyPublish): PolicyPublishDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
instanceCode: stringValue(item.instanceCode ?? item.instance_code),
|
||||||
|
instanceId: numberValue(item.instanceId ?? item.instance_id),
|
||||||
|
jobId: numberValue(item.jobId ?? item.job_id),
|
||||||
|
publishedAtMs: numberValue(item.publishedAtMs ?? item.published_at_ms),
|
||||||
|
publishedRegionIds: (item.publishedRegionIds || item.published_region_ids || []).map(numberValue),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
targetCount: numberValue(item.targetCount ?? item.target_count),
|
||||||
|
templateCode: stringValue(item.templateCode ?? item.template_code),
|
||||||
|
templateVersion: stringValue(item.templateVersion ?? item.template_version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleJson(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
114
src/features/policy-config/components/PolicyInstanceDrawer.jsx
Normal file
114
src/features/policy-config/components/PolicyInstanceDrawer.jsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import styles from "@/features/policy-config/policy-config.module.css";
|
||||||
|
|
||||||
|
export function PolicyInstanceDrawer({ page }) {
|
||||||
|
const saving = page.loadingAction === "instance-create";
|
||||||
|
const disabled = saving || !page.abilities.canCreateInstance;
|
||||||
|
const form = page.instanceForm;
|
||||||
|
const selectedTemplateValue = `${form.templateCode}:${form.templateVersion}`;
|
||||||
|
const patch = (value) => page.setInstanceForm((current) => ({ ...current, ...value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button disabled={saving} onClick={page.closeInstanceDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled} type="submit" variant="primary">
|
||||||
|
创建实例
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
as="form"
|
||||||
|
className={styles.instanceDrawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
open={page.instanceDrawerOpen}
|
||||||
|
title="创建 App 策略实例"
|
||||||
|
width="wide"
|
||||||
|
onClose={saving ? undefined : page.closeInstanceDrawer}
|
||||||
|
onSubmit={page.submitInstance}
|
||||||
|
>
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>实例配置</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
label="实例编码"
|
||||||
|
size="small"
|
||||||
|
value={form.instanceCode}
|
||||||
|
onChange={(event) => patch({ instanceCode: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
select
|
||||||
|
disabled={disabled || !page.templateOptions.length}
|
||||||
|
label="模板版本"
|
||||||
|
size="small"
|
||||||
|
value={selectedTemplateValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
const [templateCode, templateVersion] = String(event.target.value).split(":");
|
||||||
|
patch({ templateCode, templateVersion });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page.templateOptions.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
{!page.templateOptions.length ? <MenuItem value={selectedTemplateValue}>{selectedTemplateValue}</MenuItem> : null}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
disabled={disabled}
|
||||||
|
label="区域范围"
|
||||||
|
size="small"
|
||||||
|
value={form.regionScopeMode}
|
||||||
|
onChange={(event) => patch({ regionScopeMode: event.target.value, regionId: "" })}
|
||||||
|
>
|
||||||
|
<MenuItem value="all_active_regions">全部启用地区</MenuItem>
|
||||||
|
<MenuItem value="region_id">指定地区</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{form.regionScopeMode === "region_id" ? (
|
||||||
|
<RegionSelect
|
||||||
|
disabled={disabled}
|
||||||
|
emptyLabel="选择地区"
|
||||||
|
label="指定地区"
|
||||||
|
loading={page.loadingRegions}
|
||||||
|
options={page.regionOptions}
|
||||||
|
required
|
||||||
|
value={form.regionId}
|
||||||
|
onChange={(regionId) => patch({ regionId })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
disabled={disabled}
|
||||||
|
label="实例状态"
|
||||||
|
size="small"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) => patch({ status: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="active">启用</MenuItem>
|
||||||
|
<MenuItem value="disabled">停用</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TimeRangeFilter
|
||||||
|
className={styles.timeRangeField}
|
||||||
|
disabled={disabled}
|
||||||
|
label="生效时间"
|
||||||
|
value={{ endMs: form.effectiveTo, startMs: form.effectiveFrom }}
|
||||||
|
onChange={(range) => patch({ effectiveFrom: range.startMs, effectiveTo: range.endMs })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
481
src/features/policy-config/components/PolicyRuleEditorDrawer.jsx
Normal file
481
src/features/policy-config/components/PolicyRuleEditorDrawer.jsx
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
|
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
|
import styles from "@/features/policy-config/policy-config.module.css";
|
||||||
|
|
||||||
|
const agentPeriods = [["rolling_30d", "rolling_30d"]];
|
||||||
|
const bdPeriods = [["utc_calendar_month", "utc_calendar_month"]];
|
||||||
|
const gameInvitePeriods = [["rolling_30d", "rolling_30d"]];
|
||||||
|
const taskAssets = [
|
||||||
|
["POINT", "POINT"],
|
||||||
|
["COIN", "COIN"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PolicyRuleEditorDrawer({ page }) {
|
||||||
|
const saving = page.loadingAction === "template-save";
|
||||||
|
const disabled = saving || !page.abilities.canSaveTemplate;
|
||||||
|
const form = page.templateForm;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideDrawer
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button disabled={saving} onClick={page.closeTemplateDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={disabled} type="submit" variant="primary">
|
||||||
|
保存模板版本
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
as="form"
|
||||||
|
className={styles.ruleDrawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
open={page.templateDrawerOpen}
|
||||||
|
title={page.editingTemplate ? "编辑收益政策模板" : "新增收益政策模板"}
|
||||||
|
width="wide"
|
||||||
|
onClose={saving ? undefined : page.closeTemplateDrawer}
|
||||||
|
onSubmit={page.submitTemplate}
|
||||||
|
>
|
||||||
|
<TemplateBaseFields disabled={disabled} form={form} setForm={page.setTemplateForm} />
|
||||||
|
<GlobalRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<HostRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<AgentRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<BDRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<ManagerRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<CoinSellerRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<TaskAndGameRuleSection disabled={disabled} form={form.rule} setForm={page.setTemplateForm} />
|
||||||
|
<JsonEditorField
|
||||||
|
disabled={disabled}
|
||||||
|
label="高级 JSON"
|
||||||
|
minRows={12}
|
||||||
|
value={form.rule.rawJson}
|
||||||
|
onChange={(rawJson) => updateRule(page.setTemplateForm, { rawJson })}
|
||||||
|
/>
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateBaseFields({ disabled, form, setForm }) {
|
||||||
|
const patch = (value) => setForm((current) => ({ ...current, ...value }));
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>模板基础</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<TextField required disabled={disabled} label="模板名称" size="small" value={form.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||||
|
<TextField required disabled={disabled} label="模板编码" size="small" value={form.templateCode} onChange={(event) => patch({ templateCode: event.target.value })} />
|
||||||
|
<TextField required disabled={disabled} label="模板版本" size="small" value={form.templateVersion} onChange={(event) => patch({ templateVersion: event.target.value })} />
|
||||||
|
<TextField select disabled={disabled} label="状态" size="small" value={form.status} onChange={(event) => patch({ status: event.target.value })}>
|
||||||
|
<MenuItem value="active">启用</MenuItem>
|
||||||
|
<MenuItem value="disabled">停用</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField className={styles.fieldWide} disabled={disabled} label="说明" maxRows={3} multiline size="small" value={form.description} onChange={(event) => patch({ description: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalRuleSection({ disabled, form, setForm }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>全局口径</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<NumberField disabled={disabled} label="POINT/USD" value={form.pointsPerUsd} onChange={(pointsPerUsd) => updateRule(setForm, { pointsPerUsd })} />
|
||||||
|
<NumberField disabled={disabled} label="Google 金币/USD" value={form.googleCoinPerUsd} onChange={(googleCoinPerUsd) => updateRule(setForm, { googleCoinPerUsd })} />
|
||||||
|
<SwitchField
|
||||||
|
checked={form.allowSelfBrushing}
|
||||||
|
disabled={disabled}
|
||||||
|
label="允许自刷"
|
||||||
|
onChange={(allowSelfBrushing) => updateRule(setForm, { allowSelfBrushing })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostRuleSection({ disabled, form, setForm }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>主播收益</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<NumberField disabled={disabled} label="主播收益比例" suffix="%" value={form.host.pointRatioPercent} onChange={(pointRatioPercent) => updateSection(setForm, "host", { pointRatioPercent })} />
|
||||||
|
<NumberField disabled={disabled} label="最低提现积分" value={form.host.minimumWithdrawPoints} onChange={(minimumWithdrawPoints) => updateSection(setForm, "host", { minimumWithdrawPoints })} />
|
||||||
|
<NumberField disabled={disabled} label="提现手续费" suffix="bps" value={form.host.withdrawFeeBps} onChange={(withdrawFeeBps) => updateSection(setForm, "host", { withdrawFeeBps })} />
|
||||||
|
<SwitchField
|
||||||
|
checked={form.host.affectsRoomHeat}
|
||||||
|
disabled={disabled}
|
||||||
|
label="影响房间热度"
|
||||||
|
onChange={(affectsRoomHeat) => updateSection(setForm, "host", { affectsRoomHeat })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRuleSection({ disabled, form, setForm }) {
|
||||||
|
const agent = form.agent;
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>Agent 规则</h3>
|
||||||
|
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addAgentLevel(setForm)}>
|
||||||
|
添加等级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<SelectField disabled={disabled} label="周期" options={agentPeriods} value={agent.period} onChange={(period) => updateSection(setForm, "agent", { period })} />
|
||||||
|
<NumberField disabled={disabled} label="首月扶持比例" suffix="%" value={agent.supportRatioPercent} onChange={(supportRatioPercent) => updateSection(setForm, "agent", { supportRatioPercent })} />
|
||||||
|
<NumberField disabled={disabled} label="扶持天数" value={agent.supportDays} onChange={(supportDays) => updateSection(setForm, "agent", { supportDays })} />
|
||||||
|
<NumberField disabled={disabled} label="合格主播数" value={agent.qualifiedHostMin} onChange={(qualifiedHostMin) => updateSection(setForm, "agent", { qualifiedHostMin })} />
|
||||||
|
<SwitchField checked={agent.differential.enabled} disabled={disabled} label="跨级差值" onChange={(enabled) => updateAgentDifferential(setForm, { enabled })} />
|
||||||
|
<NumberField disabled={disabled} label="差值深度" value={agent.differential.maxDepth} onChange={(maxDepth) => updateAgentDifferential(setForm, { maxDepth })} />
|
||||||
|
<TextField disabled={disabled} label="差值公式" size="small" value={agent.differential.crossLevelFormula} onChange={(event) => updateAgentDifferential(setForm, { crossLevelFormula: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelTable}>
|
||||||
|
<div className={styles.agentLevelHeader}>
|
||||||
|
<span>等级</span>
|
||||||
|
<span>名称</span>
|
||||||
|
<span>最小 USD</span>
|
||||||
|
<span>最大 USD</span>
|
||||||
|
<span>比例</span>
|
||||||
|
<span>合格主播</span>
|
||||||
|
<span>人工</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{agent.levels.map((level, index) => (
|
||||||
|
<div className={styles.agentLevelRow} key={`${level.level}-${index}`}>
|
||||||
|
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateAgentLevel(setForm, index, { level: value })} />
|
||||||
|
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateAgentLevel(setForm, index, { name: event.target.value })} />
|
||||||
|
<NumberField bare disabled={disabled} value={level.minUsd} onChange={(value) => updateAgentLevel(setForm, index, { minUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} value={level.maxUsd} onChange={(value) => updateAgentLevel(setForm, index, { maxUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} suffix="%" value={level.ratioPercent} onChange={(value) => updateAgentLevel(setForm, index, { ratioPercent: value })} />
|
||||||
|
<NumberField bare disabled={disabled} value={level.qualifiedHostMin} onChange={(value) => updateAgentLevel(setForm, index, { qualifiedHostMin: value })} />
|
||||||
|
<AdminSwitch checked={level.manualReview} disabled={disabled} label={`Agent 等级 ${index + 1} 人工审核`} onChange={(event) => updateAgentLevel(setForm, index, { manualReview: event.target.checked })} />
|
||||||
|
<RemoveButton disabled={disabled || agent.levels.length <= 1} label="删除 Agent 等级" onClick={() => removeAgentLevel(setForm, index)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BDRuleSection({ disabled, form, setForm }) {
|
||||||
|
const bd = form.bd;
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>BD 规则</h3>
|
||||||
|
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addBDLevel(setForm)}>
|
||||||
|
添加等级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<SelectField disabled={disabled} label="周期" options={bdPeriods} value={bd.period} onChange={(period) => updateSection(setForm, "bd", { period })} />
|
||||||
|
<TextField disabled={disabled} label="结算基准" size="small" value={bd.base} onChange={(event) => updateSection(setForm, "bd", { base: event.target.value })} />
|
||||||
|
<NumberField disabled={disabled} label="合格 Agency 数" value={bd.qualifiedAgencyMin} onChange={(qualifiedAgencyMin) => updateSection(setForm, "bd", { qualifiedAgencyMin })} />
|
||||||
|
<NumberField disabled={disabled} label="每 Agency 主播数" value={bd.qualifiedHostPerAgencyMin} onChange={(qualifiedHostPerAgencyMin) => updateSection(setForm, "bd", { qualifiedHostPerAgencyMin })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelTable}>
|
||||||
|
<div className={styles.bdLevelHeader}>
|
||||||
|
<span>等级</span>
|
||||||
|
<span>名称</span>
|
||||||
|
<span>门槛 USD</span>
|
||||||
|
<span>固定工资</span>
|
||||||
|
<span>提成</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{bd.levels.map((level, index) => (
|
||||||
|
<div className={styles.bdLevelRow} key={`${level.level}-${index}`}>
|
||||||
|
<NumberField bare disabled={disabled} value={level.level} onChange={(value) => updateBDLevel(setForm, index, { level: value })} />
|
||||||
|
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateBDLevel(setForm, index, { name: event.target.value })} />
|
||||||
|
<NumberField bare disabled={disabled} value={level.thresholdUsd} onChange={(value) => updateBDLevel(setForm, index, { thresholdUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} prefix="$" value={level.salaryUsd} onChange={(value) => updateBDLevel(setForm, index, { salaryUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} suffix="%" value={level.commissionPercent} onChange={(value) => updateBDLevel(setForm, index, { commissionPercent: value })} />
|
||||||
|
<RemoveButton disabled={disabled || bd.levels.length <= 1} label="删除 BD 等级" onClick={() => removeBDLevel(setForm, index)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManagerRuleSection({ disabled, form, setForm }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>经理规则</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<NumberField disabled={disabled} label="币商充值门槛" prefix="$" value={form.manager.coinSellerRechargeThresholdUsd} onChange={(coinSellerRechargeThresholdUsd) => updateSection(setForm, "manager", { coinSellerRechargeThresholdUsd })} />
|
||||||
|
<NumberField disabled={disabled} label="币商充值提成" suffix="%" value={form.manager.coinSellerRechargeCommissionPercent} onChange={(coinSellerRechargeCommissionPercent) => updateSection(setForm, "manager", { coinSellerRechargeCommissionPercent })} />
|
||||||
|
<NumberField disabled={disabled} label="主播收益提成" suffix="%" value={form.manager.hostPointCommissionPercent} onChange={(hostPointCommissionPercent) => updateSection(setForm, "manager", { hostPointCommissionPercent })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoinSellerRuleSection({ disabled, form, setForm }) {
|
||||||
|
const coinSeller = form.coinSeller;
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>币商规则</h3>
|
||||||
|
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} onClick={() => addCoinSellerLevel(setForm)}>
|
||||||
|
添加档位
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<SwitchField checked={coinSeller.withdrawOrderEnabled} disabled={disabled} label="提现订单" onChange={(withdrawOrderEnabled) => updateSection(setForm, "coinSeller", { withdrawOrderEnabled })} />
|
||||||
|
<NumberField disabled={disabled} label="订单超时" suffix="秒" value={coinSeller.orderTimeoutSeconds} onChange={(orderTimeoutSeconds) => updateSection(setForm, "coinSeller", { orderTimeoutSeconds })} />
|
||||||
|
<NumberField disabled={disabled} label="币商结算" suffix="%" value={coinSeller.sellerPointSettlePercent} onChange={(sellerPointSettlePercent) => updateSection(setForm, "coinSeller", { sellerPointSettlePercent })} />
|
||||||
|
<NumberField disabled={disabled} label="币商奖励" suffix="%" value={coinSeller.sellerPointRewardPercent} onChange={(sellerPointRewardPercent) => updateSection(setForm, "coinSeller", { sellerPointRewardPercent })} />
|
||||||
|
<NumberField disabled={disabled} label="成功率关闭门槛" suffix="%" value={coinSeller.successRateCloseThresholdPercent} onChange={(successRateCloseThresholdPercent) => updateSection(setForm, "coinSeller", { successRateCloseThresholdPercent })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.levelTable}>
|
||||||
|
<div className={styles.coinSellerLevelHeader}>
|
||||||
|
<span>名称</span>
|
||||||
|
<span>累计门槛</span>
|
||||||
|
<span>单次门槛</span>
|
||||||
|
<span>金币/USD</span>
|
||||||
|
<span>订单</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{coinSeller.levels.map((level, index) => (
|
||||||
|
<div className={styles.coinSellerLevelRow} key={`${level.name}-${index}`}>
|
||||||
|
<TextField disabled={disabled} size="small" value={level.name} onChange={(event) => updateCoinSellerLevel(setForm, index, { name: event.target.value })} />
|
||||||
|
<NumberField bare disabled={disabled} prefix="$" value={level.thresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { thresholdUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} prefix="$" value={level.singleRechargeThresholdUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { singleRechargeThresholdUsd: value })} />
|
||||||
|
<NumberField bare disabled={disabled} value={level.coinPerUsd} onChange={(value) => updateCoinSellerLevel(setForm, index, { coinPerUsd: value })} />
|
||||||
|
<AdminSwitch checked={level.withdrawOrderEnabled} disabled={disabled} label={`币商档位 ${index + 1} 提现订单`} onChange={(event) => updateCoinSellerLevel(setForm, index, { withdrawOrderEnabled: event.target.checked })} />
|
||||||
|
<RemoveButton disabled={disabled || coinSeller.levels.length <= 1} label="删除币商档位" onClick={() => removeCoinSellerLevel(setForm, index)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskAndGameRuleSection({ disabled, form, setForm }) {
|
||||||
|
return (
|
||||||
|
<section className={styles.formSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>任务与游戏邀请</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<SelectField disabled={disabled} label="任务奖励资产" options={taskAssets} value={form.tasks.rewardAssetType} onChange={(rewardAssetType) => updateSection(setForm, "tasks", { rewardAssetType })} />
|
||||||
|
<NumberField disabled={disabled} label="新主播 7 日任务上限" value={form.tasks.newHost7dMaxPoints} onChange={(newHost7dMaxPoints) => updateSection(setForm, "tasks", { newHost7dMaxPoints })} />
|
||||||
|
<SwitchField checked={form.gameInvite.enabled} disabled={disabled} label="游戏邀请返利" onChange={(enabled) => updateSection(setForm, "gameInvite", { enabled })} />
|
||||||
|
<SelectField disabled={disabled} label="游戏邀请周期" options={gameInvitePeriods} value={form.gameInvite.period} onChange={(period) => updateSection(setForm, "gameInvite", { period })} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({ bare = false, disabled, label, onChange, prefix, suffix, value }) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label={bare ? undefined : label}
|
||||||
|
size="small"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { min: 0, step: "any" },
|
||||||
|
input: {
|
||||||
|
startAdornment: prefix ? <InputAdornment position="start">{prefix}</InputAdornment> : undefined,
|
||||||
|
endAdornment: suffix ? <InputAdornment position="end">{suffix}</InputAdornment> : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ disabled, label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<TextField select disabled={disabled} label={label} size="small" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
{options.map(([optionValue, optionLabel]) => (
|
||||||
|
<MenuItem key={optionValue} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchField({ checked, disabled, label, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.switchField}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<AdminSwitch checked={checked} checkedLabel="开" disabled={disabled} label={label} uncheckedLabel="关" onChange={(event) => onChange(event.target.checked)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoveButton({ disabled, label, onClick }) {
|
||||||
|
return (
|
||||||
|
<IconButton disabled={disabled} label={label} tone="danger" onClick={onClick}>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule(setForm, patch) {
|
||||||
|
setForm((current) => ({ ...current, rule: { ...current.rule, ...patch } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSection(setForm, section, patch) {
|
||||||
|
setForm((current) => ({ ...current, rule: { ...current.rule, [section]: { ...current.rule[section], ...patch } } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgentDifferential(setForm, patch) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
agent: {
|
||||||
|
...current.rule.agent,
|
||||||
|
differential: { ...current.rule.agent.differential, ...patch },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgentLevel(setForm, index, patch) {
|
||||||
|
updateArrayItem(setForm, "agent", "levels", index, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBDLevel(setForm, index, patch) {
|
||||||
|
updateArrayItem(setForm, "bd", "levels", index, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCoinSellerLevel(setForm, index, patch) {
|
||||||
|
updateArrayItem(setForm, "coinSeller", "levels", index, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArrayItem(setForm, section, key, index, patch) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
[section]: {
|
||||||
|
...current.rule[section],
|
||||||
|
[key]: current.rule[section][key].map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAgentLevel(setForm) {
|
||||||
|
setForm((current) => {
|
||||||
|
const last = current.rule.agent.levels[current.rule.agent.levels.length - 1];
|
||||||
|
const nextLevel = Number(last?.level || current.rule.agent.levels.length) + 1;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
agent: {
|
||||||
|
...current.rule.agent,
|
||||||
|
levels: [
|
||||||
|
...current.rule.agent.levels,
|
||||||
|
{
|
||||||
|
level: String(nextLevel),
|
||||||
|
manualReview: false,
|
||||||
|
maxUsd: "0",
|
||||||
|
minUsd: last?.maxUsd || "0",
|
||||||
|
name: `Lv${nextLevel}`,
|
||||||
|
qualifiedHostMin: last?.qualifiedHostMin || "0",
|
||||||
|
ratioPercent: last?.ratioPercent || "0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBDLevel(setForm) {
|
||||||
|
setForm((current) => {
|
||||||
|
const last = current.rule.bd.levels[current.rule.bd.levels.length - 1];
|
||||||
|
const nextLevel = Number(last?.level || current.rule.bd.levels.length) + 1;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
bd: {
|
||||||
|
...current.rule.bd,
|
||||||
|
levels: [
|
||||||
|
...current.rule.bd.levels,
|
||||||
|
{
|
||||||
|
commissionPercent: last?.commissionPercent || "0",
|
||||||
|
level: String(nextLevel),
|
||||||
|
name: `BD-${nextLevel}`,
|
||||||
|
salaryUsd: last?.salaryUsd || "0",
|
||||||
|
thresholdUsd: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCoinSellerLevel(setForm) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
coinSeller: {
|
||||||
|
...current.rule.coinSeller,
|
||||||
|
levels: [
|
||||||
|
...current.rule.coinSeller.levels,
|
||||||
|
{
|
||||||
|
coinPerUsd: "0",
|
||||||
|
name: "",
|
||||||
|
singleRechargeThresholdUsd: "",
|
||||||
|
thresholdUsd: "",
|
||||||
|
withdrawOrderEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAgentLevel(setForm, index) {
|
||||||
|
removeArrayItem(setForm, "agent", "levels", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBDLevel(setForm, index) {
|
||||||
|
removeArrayItem(setForm, "bd", "levels", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCoinSellerLevel(setForm, index) {
|
||||||
|
removeArrayItem(setForm, "coinSeller", "levels", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeArrayItem(setForm, section, key, index) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rule: {
|
||||||
|
...current.rule,
|
||||||
|
[section]: {
|
||||||
|
...current.rule[section],
|
||||||
|
[key]: current.rule[section][key].filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
247
src/features/policy-config/hooks/usePolicyConfigPage.js
Normal file
247
src/features/policy-config/hooks/usePolicyConfigPage.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import {
|
||||||
|
createPolicyInstance,
|
||||||
|
listPolicyInstances,
|
||||||
|
listPolicyTemplates,
|
||||||
|
publishPolicyInstance,
|
||||||
|
savePolicyTemplate,
|
||||||
|
updatePolicyInstanceStatus,
|
||||||
|
} from "@/features/policy-config/api";
|
||||||
|
import { usePolicyConfigAbilities } from "@/features/policy-config/permissions.js";
|
||||||
|
import {
|
||||||
|
policyInstanceFormFromTemplate,
|
||||||
|
policyInstancePayloadFromForm,
|
||||||
|
policyTemplateFormFromItem,
|
||||||
|
policyTemplatePayloadFromForm,
|
||||||
|
} from "@/features/policy-config/schema";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
export function usePolicyConfigPage() {
|
||||||
|
const abilities = usePolicyConfigAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [activeTab, setActiveTab] = useState("templates");
|
||||||
|
const [templateKeyword, setTemplateKeyword] = useState("");
|
||||||
|
const [templateStatus, setTemplateStatus] = useState("");
|
||||||
|
const [templatePage, setTemplatePage] = useState(1);
|
||||||
|
const [instanceKeyword, setInstanceKeyword] = useState("");
|
||||||
|
const [instanceStatus, setInstanceStatus] = useState("");
|
||||||
|
const [instancePage, setInstancePage] = useState(1);
|
||||||
|
const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
|
||||||
|
const [instanceDrawerOpen, setInstanceDrawerOpen] = useState(false);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState(null);
|
||||||
|
const [templateForm, setTemplateForm] = useState(() => policyTemplateFormFromItem(null));
|
||||||
|
const [instanceForm, setInstanceForm] = useState(() => policyInstanceFormFromTemplate(null));
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
const templateFilters = useMemo(
|
||||||
|
() => ({ keyword: templateKeyword, status: templateStatus }),
|
||||||
|
[templateKeyword, templateStatus],
|
||||||
|
);
|
||||||
|
const instanceFilters = useMemo(
|
||||||
|
() => ({ keyword: instanceKeyword, status: instanceStatus }),
|
||||||
|
[instanceKeyword, instanceStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const templates = usePaginatedQuery({
|
||||||
|
errorMessage: "加载收益政策模板失败",
|
||||||
|
fetcher: listPolicyTemplates,
|
||||||
|
filters: templateFilters,
|
||||||
|
page: templatePage,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["policy-templates", toPageQuery({ ...templateFilters, page: templatePage, pageSize })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const instances = usePaginatedQuery({
|
||||||
|
errorMessage: "加载收益政策实例失败",
|
||||||
|
fetcher: listPolicyInstances,
|
||||||
|
filters: instanceFilters,
|
||||||
|
page: instancePage,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["policy-instances", toPageQuery({ ...instanceFilters, page: instancePage, pageSize })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(templates.data.items || []).map((template) => ({
|
||||||
|
label: `${template.name || template.templateCode} · ${template.templateVersion}`,
|
||||||
|
templateCode: template.templateCode,
|
||||||
|
templateVersion: template.templateVersion,
|
||||||
|
value: `${template.templateCode}:${template.templateVersion}`,
|
||||||
|
})),
|
||||||
|
[templates.data.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const regionLabelMap = useMemo(
|
||||||
|
() =>
|
||||||
|
regionOptions.reduce((labels, region) => {
|
||||||
|
labels[String(region.regionId)] = region.label;
|
||||||
|
return labels;
|
||||||
|
}, {}),
|
||||||
|
[regionOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTemplateEditor = (template = null) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
setTemplateForm(policyTemplateFormFromItem(template));
|
||||||
|
setTemplateDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTemplateDrawer = () => {
|
||||||
|
if (loadingAction === "template-save") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTemplateDrawerOpen(false);
|
||||||
|
setEditingTemplate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInstanceCreator = (template = null) => {
|
||||||
|
setInstanceForm(policyInstanceFormFromTemplate(template || templates.data.items?.[0] || null));
|
||||||
|
setInstanceDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInstanceDrawer = () => {
|
||||||
|
if (loadingAction === "instance-create") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInstanceDrawerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTemplate = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canSaveTemplate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("template-save", "收益政策模板已保存", async () => {
|
||||||
|
// 模板 POST 是 code+version 维度的 upsert;保存只更新 admin 草稿,运行侧必须再发布实例。
|
||||||
|
await savePolicyTemplate(policyTemplatePayloadFromForm(templateForm));
|
||||||
|
setTemplateDrawerOpen(false);
|
||||||
|
setEditingTemplate(null);
|
||||||
|
await templates.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitInstance = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canCreateInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction("instance-create", "收益政策实例已创建", async () => {
|
||||||
|
// app_code 不在表单中提交,由 apiRequest 的 X-App-Code 和 admin-server appctx 共同确定。
|
||||||
|
await createPolicyInstance(policyInstancePayloadFromForm(instanceForm));
|
||||||
|
setInstanceDrawerOpen(false);
|
||||||
|
await instances.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInstanceStatus = async (instance, nextEnabled = instance.status !== "active") => {
|
||||||
|
if (!instance?.instanceId || !abilities.canUpdateInstance || (instance.status === "active") === nextEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`instance-status-${instance.instanceId}`, nextEnabled ? "收益政策实例已启用" : "收益政策实例已停用", async () => {
|
||||||
|
// 停用实例要同步 owner service 运行表;不能只在前端隐藏。
|
||||||
|
await updatePolicyInstanceStatus(instance.instanceId, nextEnabled ? "active" : "disabled");
|
||||||
|
await instances.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishInstance = async (instance) => {
|
||||||
|
if (!instance?.instanceId || !abilities.canPublishInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`instance-publish-${instance.instanceId}`, "收益政策实例已发布", async () => {
|
||||||
|
// 发布是 admin 配置进入 wallet/activity runtime 的唯一入口;模板保存本身不会影响线上结算。
|
||||||
|
await publishPolicyInstance(instance.instanceId);
|
||||||
|
await instances.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTemplateKeyword = (value) => {
|
||||||
|
setTemplateKeyword(value);
|
||||||
|
setTemplatePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTemplateStatus = (value) => {
|
||||||
|
setTemplateStatus(value);
|
||||||
|
setTemplatePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeInstanceKeyword = (value) => {
|
||||||
|
setInstanceKeyword(value);
|
||||||
|
setInstancePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeInstanceStatus = (value) => {
|
||||||
|
setInstanceStatus(value);
|
||||||
|
setInstancePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTemplateFilters = () => {
|
||||||
|
setTemplateKeyword("");
|
||||||
|
setTemplateStatus("");
|
||||||
|
setTemplatePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetInstanceFilters = () => {
|
||||||
|
setInstanceKeyword("");
|
||||||
|
setInstanceStatus("");
|
||||||
|
setInstancePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
await submitter();
|
||||||
|
showToast(successMessage, "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error?.message || "操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
activeTab,
|
||||||
|
changeInstanceKeyword,
|
||||||
|
changeInstanceStatus,
|
||||||
|
changeTemplateKeyword,
|
||||||
|
changeTemplateStatus,
|
||||||
|
closeInstanceDrawer,
|
||||||
|
closeTemplateDrawer,
|
||||||
|
editingTemplate,
|
||||||
|
instanceDrawerOpen,
|
||||||
|
instanceFilters: { keyword: instanceKeyword, status: instanceStatus },
|
||||||
|
instanceForm,
|
||||||
|
instancePage,
|
||||||
|
instances,
|
||||||
|
loadingAction,
|
||||||
|
loadingRegions,
|
||||||
|
openInstanceCreator,
|
||||||
|
openTemplateEditor,
|
||||||
|
publishInstance,
|
||||||
|
regionLabelMap,
|
||||||
|
regionOptions,
|
||||||
|
resetInstanceFilters,
|
||||||
|
resetTemplateFilters,
|
||||||
|
setActiveTab,
|
||||||
|
setInstanceForm,
|
||||||
|
setInstancePage,
|
||||||
|
setTemplateForm,
|
||||||
|
setTemplatePage,
|
||||||
|
submitInstance,
|
||||||
|
submitTemplate,
|
||||||
|
templateDrawerOpen,
|
||||||
|
templateFilters: { keyword: templateKeyword, status: templateStatus },
|
||||||
|
templateForm,
|
||||||
|
templateOptions,
|
||||||
|
templatePage,
|
||||||
|
templates,
|
||||||
|
toggleInstanceStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
350
src/features/policy-config/pages/PolicyConfigPage.jsx
Normal file
350
src/features/policy-config/pages/PolicyConfigPage.jsx
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import CloudUploadOutlined from "@mui/icons-material/CloudUploadOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminSearchBox,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { PolicyInstanceDrawer } from "@/features/policy-config/components/PolicyInstanceDrawer.jsx";
|
||||||
|
import { PolicyRuleEditorDrawer } from "@/features/policy-config/components/PolicyRuleEditorDrawer.jsx";
|
||||||
|
import { usePolicyConfigPage } from "@/features/policy-config/hooks/usePolicyConfigPage.js";
|
||||||
|
import styles from "@/features/policy-config/policy-config.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const templateColumns = [
|
||||||
|
{
|
||||||
|
key: "template",
|
||||||
|
label: "模板",
|
||||||
|
width: "minmax(320px, 1.2fr)",
|
||||||
|
render: (item) => <TemplateIdentity item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "runtime",
|
||||||
|
label: "核心运行字段",
|
||||||
|
width: "minmax(240px, 0.9fr)",
|
||||||
|
render: (item) => <TemplateRuntimeSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bd",
|
||||||
|
label: "团队政策",
|
||||||
|
width: "minmax(220px, 0.8fr)",
|
||||||
|
render: (item) => <TeamPolicySummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "120px",
|
||||||
|
render: (item) => statusLabel(item.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "96px",
|
||||||
|
resizable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const instanceColumns = [
|
||||||
|
{
|
||||||
|
key: "instance",
|
||||||
|
label: "实例",
|
||||||
|
width: "minmax(300px, 1.1fr)",
|
||||||
|
render: (item) => <InstanceIdentity item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "范围与时间",
|
||||||
|
width: "minmax(260px, 0.9fr)",
|
||||||
|
render: (item, _index, context) => <InstanceScope item={item} regionLabelMap={context.regionLabelMap} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "publish",
|
||||||
|
label: "发布",
|
||||||
|
width: "minmax(220px, 0.85fr)",
|
||||||
|
render: (item) => <PublishSummary item={item} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "120px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "180px",
|
||||||
|
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "144px",
|
||||||
|
resizable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PolicyConfigPage() {
|
||||||
|
const page = usePolicyConfigPage();
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<div className={styles.tabsBar}>
|
||||||
|
<Tabs value={page.activeTab} onChange={(_, value) => page.setActiveTab(value)}>
|
||||||
|
<Tab className={styles.tab} label="模板规则" value="templates" />
|
||||||
|
<Tab className={styles.tab} label="App 实例" value="instances" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
{page.activeTab === "templates" ? <TemplatePanel page={page} /> : <InstancePanel page={page} />}
|
||||||
|
<PolicyRuleEditorDrawer page={page} />
|
||||||
|
<PolicyInstanceDrawer page={page} />
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplatePanel({ page }) {
|
||||||
|
const items = page.templates.data.items || [];
|
||||||
|
const total = page.templates.data.total || 0;
|
||||||
|
const columns = templateColumns.map((column) =>
|
||||||
|
column.key === "actions" ? { ...column, render: (item) => <TemplateActions item={item} page={page} /> } : column,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canSaveTemplate ? (
|
||||||
|
<AdminActionIconButton label="新增收益政策模板" primary onClick={() => page.openTemplateEditor(null)}>
|
||||||
|
<AddOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<AdminSearchBox label="模板" placeholder="搜索模板名称或编码" value={page.templateFilters.keyword} onChange={page.changeTemplateKeyword} />
|
||||||
|
<AdminFilterSelect label="状态" options={statusOptions} value={page.templateFilters.status} onChange={page.changeTemplateStatus} />
|
||||||
|
<AdminFilterResetButton disabled={!page.templateFilters.keyword && !page.templateFilters.status} onClick={page.resetTemplateFilters} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.templates.error} loading={page.templates.loading} onRetry={page.templates.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={items}
|
||||||
|
minWidth="1240px"
|
||||||
|
pagination={total > 0 ? { page: page.templatePage, pageSize: page.templates.data.pageSize || 50, total, onPageChange: page.setTemplatePage } : undefined}
|
||||||
|
rowKey={(item) => `${item.templateCode}:${item.templateVersion}`}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstancePanel({ page }) {
|
||||||
|
const items = page.instances.data.items || [];
|
||||||
|
const total = page.instances.data.total || 0;
|
||||||
|
const columns = instanceColumns.map((column) => {
|
||||||
|
if (column.key === "status") {
|
||||||
|
return { ...column, render: (item) => <InstanceStatusSwitch item={item} page={page} /> };
|
||||||
|
}
|
||||||
|
if (column.key === "actions") {
|
||||||
|
return { ...column, render: (item) => <InstanceActions item={item} page={page} /> };
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canCreateInstance ? (
|
||||||
|
<AdminActionIconButton label="创建策略实例" primary onClick={() => page.openInstanceCreator(null)}>
|
||||||
|
<AddOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<div className={styles.toolbarFilters}>
|
||||||
|
<AdminSearchBox label="实例" placeholder="搜索实例或模板编码" value={page.instanceFilters.keyword} onChange={page.changeInstanceKeyword} />
|
||||||
|
<AdminFilterSelect label="状态" options={statusOptions} value={page.instanceFilters.status} onChange={page.changeInstanceStatus} />
|
||||||
|
<AdminFilterResetButton disabled={!page.instanceFilters.keyword && !page.instanceFilters.status} onClick={page.resetInstanceFilters} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.instances.error} loading={page.instances.loading} onRetry={page.instances.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{ regionLabelMap: page.regionLabelMap }}
|
||||||
|
items={items}
|
||||||
|
minWidth="1260px"
|
||||||
|
pagination={total > 0 ? { page: page.instancePage, pageSize: page.instances.data.pageSize || 50, total, onPageChange: page.setInstancePage } : undefined}
|
||||||
|
rowKey={(item) => item.instanceId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateIdentity({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.name}</span>
|
||||||
|
<span className={styles.meta}>{item.templateCode}</span>
|
||||||
|
<span className={styles.meta}>版本 {item.templateVersion}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateRuntimeSummary({ item }) {
|
||||||
|
const rule = item.ruleJson || {};
|
||||||
|
const host = rule.host || {};
|
||||||
|
const tasks = rule.tasks || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{formatNumber(rule.points_per_usd)} POINT/USD</span>
|
||||||
|
<span className={styles.meta}>主播 {formatNumber(host.point_ratio_percent)}% 入 POINT</span>
|
||||||
|
<span className={styles.meta}>提现手续费 {formatNumber(host.withdraw_fee_bps)} bps</span>
|
||||||
|
<span className={styles.meta}>任务资产 {tasks.reward_asset_type || "-"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamPolicySummary({ item }) {
|
||||||
|
const rule = item.ruleJson || {};
|
||||||
|
const agentLevels = Array.isArray(rule.agent?.levels) ? rule.agent.levels : [];
|
||||||
|
const bdLevels = Array.isArray(rule.bd?.levels) ? rule.bd.levels : [];
|
||||||
|
const coinSellerLevels = Array.isArray(rule.coin_seller?.levels) ? rule.coin_seller.levels : [];
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>Agent {agentLevels.length} 档 · BD {bdLevels.length} 档</span>
|
||||||
|
<span className={styles.meta}>币商 {coinSellerLevels.length} 档</span>
|
||||||
|
<span className={styles.meta}>{rule.allow_self_brushing ? "允许自刷" : "不允许自刷"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateActions({ item, page }) {
|
||||||
|
if (!page.abilities.canSaveTemplate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AdminActionIconButton label="编辑模板规则" onClick={() => page.openTemplateEditor(item)}>
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceIdentity({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.instanceCode}</span>
|
||||||
|
<span className={styles.meta}>{item.appCode || "-"}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{item.templateCode} · {item.templateVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceScope({ item, regionLabelMap = {} }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{regionScopeLabel(item.regionScope, regionLabelMap)}</span>
|
||||||
|
<span className={styles.meta}>开始 {formatMillis(item.effectiveFromMs)}</span>
|
||||||
|
<span className={styles.meta}>结束 {item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期有效"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublishSummary({ item }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{publishStatusLabel(item.publishStatus)}</span>
|
||||||
|
<span className={styles.meta}>{item.lastPublishedAtMs ? formatMillis(item.lastPublishedAtMs) : "未发布"}</span>
|
||||||
|
{item.publishError ? <span className={styles.errorText}>{item.publishError}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceStatusSwitch({ item, page }) {
|
||||||
|
const action = `instance-status-${item.instanceId}`;
|
||||||
|
return (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={item.status === "active"}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={!page.abilities.canUpdateInstance || page.loadingAction === action}
|
||||||
|
label="策略实例状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => page.toggleInstanceStatus(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceActions({ item, page }) {
|
||||||
|
const action = `instance-publish-${item.instanceId}`;
|
||||||
|
if (!page.abilities.canPublishInstance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={item.status !== "active" || page.loadingAction === action}
|
||||||
|
label="发布到运行侧"
|
||||||
|
primary
|
||||||
|
onClick={() => page.publishInstance(item)}
|
||||||
|
>
|
||||||
|
<CloudUploadOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return status === "active" ? "启用" : "停用";
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishStatusLabel(status) {
|
||||||
|
switch (status) {
|
||||||
|
case "published":
|
||||||
|
return "已发布";
|
||||||
|
case "failed":
|
||||||
|
return "发布失败";
|
||||||
|
default:
|
||||||
|
return "草稿";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionScopeLabel(scope, labels) {
|
||||||
|
if (scope === "all_active_regions" || !scope) {
|
||||||
|
return "全部启用地区";
|
||||||
|
}
|
||||||
|
if (String(scope).startsWith("region_id:")) {
|
||||||
|
const regionId = String(scope).replace("region_id:", "");
|
||||||
|
return labels[regionId] || `区域 ${regionId}`;
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number.toLocaleString("en-US") : "-";
|
||||||
|
}
|
||||||
14
src/features/policy-config/permissions.js
Normal file
14
src/features/policy-config/permissions.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function usePolicyConfigAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canCreateInstance: can(PERMISSIONS.policyInstanceCreate),
|
||||||
|
canPublishInstance: can(PERMISSIONS.policyInstancePublish),
|
||||||
|
canSaveTemplate: can(PERMISSIONS.policyTemplateCreate),
|
||||||
|
canUpdateInstance: can(PERMISSIONS.policyInstanceUpdate),
|
||||||
|
canView: can(PERMISSIONS.policyTemplateView),
|
||||||
|
};
|
||||||
|
}
|
||||||
174
src/features/policy-config/policy-config.module.css
Normal file
174
src/features/policy-config/policy-config.module.css
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
.tabsBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
min-height: 48px;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarFilters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleDrawer,
|
||||||
|
.instanceDrawer {
|
||||||
|
width: min(1120px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldWide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeField {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchField {
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelTable {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentLevelHeader,
|
||||||
|
.agentLevelRow,
|
||||||
|
.bdLevelHeader,
|
||||||
|
.bdLevelRow,
|
||||||
|
.coinSellerLevelHeader,
|
||||||
|
.coinSellerLevelRow {
|
||||||
|
display: grid;
|
||||||
|
min-width: 920px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentLevelHeader,
|
||||||
|
.bdLevelHeader,
|
||||||
|
.coinSellerLevelHeader {
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentLevelRow,
|
||||||
|
.bdLevelRow,
|
||||||
|
.coinSellerLevelRow {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentLevelHeader,
|
||||||
|
.agentLevelRow {
|
||||||
|
grid-template-columns: 72px minmax(130px, 1fr) 110px 110px 120px 110px 76px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bdLevelHeader,
|
||||||
|
.bdLevelRow {
|
||||||
|
grid-template-columns: 72px minmax(160px, 1fr) 130px 130px 120px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coinSellerLevelHeader,
|
||||||
|
.coinSellerLevelRow {
|
||||||
|
grid-template-columns: minmax(160px, 1fr) 130px 130px 130px 92px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.formGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRangeField {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/features/policy-config/routes.js
Normal file
12
src/features/policy-config/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const policyConfigRoutes = [
|
||||||
|
{
|
||||||
|
label: "收益政策模板",
|
||||||
|
loader: () => import("./pages/PolicyConfigPage.jsx").then((module) => module.PolicyConfigPage),
|
||||||
|
menuCode: MENU_CODES.policyTemplate,
|
||||||
|
pageKey: "policy-config",
|
||||||
|
path: "/policy/templates",
|
||||||
|
permission: PERMISSIONS.policyTemplateView,
|
||||||
|
},
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user