Compare commits
9 Commits
00b8020599
...
c836d0f702
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c836d0f702 | ||
|
|
18ad13563b | ||
|
|
8fe2efb344 | ||
|
|
91a7c784c9 | ||
|
|
b0b1a26ae8 | ||
|
|
810ee6721c | ||
|
|
b81c2edf43 | ||
|
|
a952b4262e | ||
|
|
f5e1a20dd9 |
File diff suppressed because it is too large
Load Diff
12
databi/social/index.html
Normal file
12
databi/social/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 Social BI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="databi-root"></div>
|
||||||
|
<script type="module" src="/databi/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
|
||||||
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||||
@ -11,16 +11,24 @@ import { ReportOverview } from "./components/ReportOverview.jsx";
|
|||||||
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
|
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||||
|
import { SocialBiDashboard } from "./components/SocialBiDashboard.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||||
|
|
||||||
const initialFilterOptions = {
|
const initialFilterOptions = {
|
||||||
appOptions: [],
|
appOptions: getDefaultAppOptions(),
|
||||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DatabiApp() {
|
export function DatabiApp() {
|
||||||
|
if (isSocialBiPath()) {
|
||||||
|
return <SocialBiDashboard />;
|
||||||
|
}
|
||||||
|
return <DatabiOverviewApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatabiOverviewApp() {
|
||||||
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
|
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
|
||||||
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
|
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
|
||||||
const [timeZone, setTimeZone] = useState(initialTimeZone);
|
const [timeZone, setTimeZone] = useState(initialTimeZone);
|
||||||
@ -29,7 +37,6 @@ export function DatabiApp() {
|
|||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
const [viewMode, setViewMode] = useState("report");
|
const [viewMode, setViewMode] = useState("report");
|
||||||
// 侧边栏目前只有数据报表接入真实业务流,财务和运营保持空组件,避免误触发数据报表请求。
|
|
||||||
const [activeReportSection, setActiveReportSection] = useState("data");
|
const [activeReportSection, setActiveReportSection] = useState("data");
|
||||||
const [activeScreen, setActiveScreen] = useState("overview");
|
const [activeScreen, setActiveScreen] = useState("overview");
|
||||||
const [gameId, setGameId] = useState("all");
|
const [gameId, setGameId] = useState("all");
|
||||||
@ -174,7 +181,6 @@ export function DatabiApp() {
|
|||||||
}, [appCode, countryId, gameId, range, regionId, timeZone]);
|
}, [appCode, countryId, gameId, range, regionId, timeZone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 非数据报表没有筛选控件,提前停止筛选项加载,避免空报表页产生无意义网络请求。
|
|
||||||
if (!isDataReportSection) {
|
if (!isDataReportSection) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -182,7 +188,6 @@ export function DatabiApp() {
|
|||||||
}, [isDataReportSection, loadFilterOptions]);
|
}, [isDataReportSection, loadFilterOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 当前数据轮询只属于数据报表;切到财务或运营时清理定时器并保留已有数据,方便切回。
|
|
||||||
if (!isDataReportSection) {
|
if (!isDataReportSection) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -254,6 +259,14 @@ export function DatabiApp() {
|
|||||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||||
) : isReportView ? (
|
) : isReportView ? (
|
||||||
<ReportOverview
|
<ReportOverview
|
||||||
|
grantDrilldownQuery={{
|
||||||
|
appCode,
|
||||||
|
countryId,
|
||||||
|
endMs: rangeEndMs(range, timeZone),
|
||||||
|
regionId,
|
||||||
|
startMs: rangeStartMs(range, timeZone),
|
||||||
|
statTz: timeZone
|
||||||
|
}}
|
||||||
loading={showSkeleton}
|
loading={showSkeleton}
|
||||||
model={model}
|
model={model}
|
||||||
onMetricTrend={setTrendModalItem}
|
onMetricTrend={setTrendModalItem}
|
||||||
@ -273,9 +286,7 @@ export function DatabiApp() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isDataReportSection && activeScreen === "overview" && !isReportView ? (
|
{isDataReportSection && activeScreen === "overview" && !isReportView ? (
|
||||||
<>
|
|
||||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{isDataReportSection && activeScreen === "overview" && poolModalOpen ? (
|
{isDataReportSection && activeScreen === "overview" && poolModalOpen ? (
|
||||||
<LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} />
|
<LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} />
|
||||||
@ -325,10 +336,17 @@ function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools
|
|||||||
|
|
||||||
function resolveSelectedAppCode(options, current) {
|
function resolveSelectedAppCode(options, current) {
|
||||||
const normalized = String(current || "").trim().toLowerCase();
|
const normalized = String(current || "").trim().toLowerCase();
|
||||||
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
|
if (options.some((item) => optionAppCode(item) === normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase();
|
if (options.length) {
|
||||||
|
return optionAppCode(options[0]);
|
||||||
|
}
|
||||||
|
return normalized || "lalu";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionAppCode(option) {
|
||||||
|
return String(option?.code ?? option?.value ?? "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRangePresetValue(range, timeZone) {
|
function resolveRangePresetValue(range, timeZone) {
|
||||||
@ -393,3 +411,7 @@ function enrichCountryBreakdown(data, countries) {
|
|||||||
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
|
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSocialBiPath() {
|
||||||
|
return window.location.pathname.replace(/\/+$/, "") === "/databi/social";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
import { act, render, screen, within } from "@testing-library/react";
|
import { act, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchSocialBiFilterOptions, fetchStatisticsOverview } from "./api.js";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
fetchFilterOptions: vi.fn(),
|
fetchFilterOptions: vi.fn(),
|
||||||
|
fetchSocialBiFilterOptions: vi.fn(),
|
||||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||||
fetchStatisticsOverview: vi.fn(),
|
fetchStatisticsOverview: vi.fn(),
|
||||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
|
getDefaultAppOptions: vi.fn(() => [
|
||||||
|
{ code: "", label: "全部", value: "" },
|
||||||
|
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
]),
|
||||||
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -19,14 +26,29 @@ beforeEach(() => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
|
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
|
||||||
fetchFilterOptions.mockResolvedValue({
|
fetchFilterOptions.mockResolvedValue({
|
||||||
appOptions: [{ code: "lalu", label: "Lalu", value: "lalu" }],
|
appOptions: [
|
||||||
|
{ code: "", label: "全部", value: "" },
|
||||||
|
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
],
|
||||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
});
|
});
|
||||||
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
||||||
|
fetchSocialBiFilterOptions.mockResolvedValue({
|
||||||
|
appOptions: [
|
||||||
|
{ label: "全部 App", value: "all" },
|
||||||
|
{ label: "Lalu", value: "lalu" },
|
||||||
|
{ label: "Aslan", value: "aslan" },
|
||||||
|
{ label: "Yumi", value: "yumi" }
|
||||||
|
],
|
||||||
|
operatorOptions: [{ label: "全部人员", searchText: "all 全部人员", value: "all" }]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
window.history.pushState(null, "", "/");
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@ -70,7 +92,7 @@ test("queries China natural day statistics when display timezone is Beijing", as
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders coin seller stock and outbound coin columns in report view", async () => {
|
test("renders legacy app metric columns in report view", async () => {
|
||||||
fetchStatisticsOverview.mockResolvedValue({
|
fetchStatisticsOverview.mockResolvedValue({
|
||||||
coin_seller_stock_coin: 400_000,
|
coin_seller_stock_coin: 400_000,
|
||||||
coin_seller_transfer_coin: 160_000,
|
coin_seller_transfer_coin: 160_000,
|
||||||
@ -188,6 +210,69 @@ test("renders real-room robot gift cards in a separate row", async () => {
|
|||||||
expect(within(robotGiftSection).getByText("真人房机器人房均礼物")).toBeTruthy();
|
expect(within(robotGiftSection).getByText("真人房机器人房均礼物")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("routes databi social page to Social BI and loads real overview rows", async () => {
|
||||||
|
window.history.pushState(null, "", "/databi/social/");
|
||||||
|
fetchStatisticsOverview.mockImplementation(async ({ appCode }) => ({
|
||||||
|
active_users: appCode === "lalu" ? 20 : 10,
|
||||||
|
app_name: appCode === "lalu" ? "Lalu" : appCode === "aslan" ? "Aslan" : "Yumi",
|
||||||
|
arpu_usd_minor: 410,
|
||||||
|
arppu_usd_minor: appCode === "aslan" ? null : 615,
|
||||||
|
avg_mic_online_ms: 120_000,
|
||||||
|
coin_seller_recharge_usd_minor: 3400,
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
coin_total: appCode === "aslan" ? 5000 : null,
|
||||||
|
consumed_coin: 900_000,
|
||||||
|
consume_output_delta: 450_000,
|
||||||
|
consume_output_ratio: 0.5,
|
||||||
|
country_breakdown: [],
|
||||||
|
game_profit_rate: 0.17,
|
||||||
|
game_turnover: 300_000,
|
||||||
|
gift_coin_spent: 123_456,
|
||||||
|
google_recharge_usd_minor: 700,
|
||||||
|
lucky_gift_profit_rate: 0.25,
|
||||||
|
lucky_gift_turnover: 200_000,
|
||||||
|
manual_grant_coin: 222,
|
||||||
|
mic_online_ms: 3_600_000,
|
||||||
|
mifapay_recharge_usd_minor: 800,
|
||||||
|
new_user_recharge_usd_minor: 1200,
|
||||||
|
new_users: appCode === "lalu" ? 3 : 1,
|
||||||
|
output_coin: 450_000,
|
||||||
|
paid_conversion_rate: 0.2,
|
||||||
|
paid_users: 4,
|
||||||
|
platform_grant_coin: 111,
|
||||||
|
recharge_usd_minor: appCode === "lalu" ? 12300 : 5000,
|
||||||
|
recharge_conversion_rate: 0.2,
|
||||||
|
recharge_users: 4,
|
||||||
|
retention: { day1_rate: 0.25, day7_rate: 0.1, day30_rate: 0.05 },
|
||||||
|
salary_transfer_coin: 88_000,
|
||||||
|
salary_usd_minor: appCode === "aslan" ? 2500 : null,
|
||||||
|
super_lucky_gift_profit_rate: 0.08,
|
||||||
|
super_lucky_gift_turnover: 50_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<DatabiApp />);
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("App 数据明细")).toBeNull();
|
||||||
|
expect(fetchSocialBiFilterOptions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", statTz: "Asia/Shanghai" }));
|
||||||
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "aslan", statTz: "Asia/Shanghai" }));
|
||||||
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "yumi", statTz: "Asia/Shanghai" }));
|
||||||
|
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("付费用户")).toBeTruthy();
|
||||||
|
expect(screen.getByText("游戏流水/利润率")).toBeTruthy();
|
||||||
|
expect(screen.getByText("消耗产出比")).toBeTruthy();
|
||||||
|
expect(screen.getByText("ARPU ($)")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("300,000").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("17.0%").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("2分").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText("$284,300")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
async function flushEffects() {
|
async function flushEffects() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let index = 0; index < 10; index += 1) {
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
|||||||
@ -1,23 +1,37 @@
|
|||||||
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
|
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
|
||||||
|
import { API_BASE_URL } from "../../src/shared/config/env.js";
|
||||||
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
|
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
|
|
||||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
const APP_CODE_KEY = "hyapp-admin.app-code";
|
||||||
const APP_CODE_HEADER = "X-App-Code";
|
const APP_CODE_HEADER = "X-App-Code";
|
||||||
|
const DEFAULT_APP_CODE = "lalu";
|
||||||
|
const ALL_APP_OPTION = { code: "", label: "全部", value: "" };
|
||||||
|
const DEFAULT_APP_OPTION = { code: DEFAULT_APP_CODE, label: "Lalu", value: DEFAULT_APP_CODE };
|
||||||
|
const FIXED_APP_OPTIONS = [
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
];
|
||||||
|
const SOCIAL_BI_ALL_VALUE = "all";
|
||||||
|
const SOCIAL_BI_OPERATION_TEAM_ID = 2;
|
||||||
|
|
||||||
|
export function getDefaultAppOptions() {
|
||||||
|
return mergeAppOptions([ALL_APP_OPTION, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentAppCode() {
|
export function getCurrentAppCode() {
|
||||||
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
const stored = window.localStorage.getItem(APP_CODE_KEY);
|
||||||
|
return stored === null ? DEFAULT_APP_CODE : normalizeAppCode(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCurrentAppCode(value) {
|
export function setCurrentAppCode(value) {
|
||||||
const appCode = normalizeAppCode(value) || "lalu";
|
const appCode = normalizeAppCode(value);
|
||||||
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
||||||
return appCode;
|
return appCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) {
|
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) {
|
||||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
const query = appScopedQuery(appCode);
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
query.stat_tz = statTz;
|
query.stat_tz = statTz;
|
||||||
}
|
}
|
||||||
@ -40,11 +54,25 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
|
|||||||
query.region_id = String(regionId);
|
query.region_id = String(regionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.statisticsOverview), { appCode, query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformGrantUsers({ appCode, countryId, endMs, page, pageSize, regionId, startMs, statDay, statTz }) {
|
||||||
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.listPlatformGrantUsers), {
|
||||||
|
appCode,
|
||||||
|
query: platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, startMs, statDay, statTz })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformGrantRecords({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId }) {
|
||||||
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.listPlatformGrantRecords), {
|
||||||
|
appCode,
|
||||||
|
query: platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) {
|
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) {
|
||||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
const query = appScopedQuery(appCode);
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
query.stat_tz = statTz;
|
query.stat_tz = statTz;
|
||||||
}
|
}
|
||||||
@ -64,7 +92,42 @@ export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endM
|
|||||||
query.game_id = String(gameId);
|
query.game_id = String(gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchDatabiData("/v1/statistics/self-games/overview", { appCode, query });
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.selfGameStatisticsOverview), { appCode, query });
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId }) {
|
||||||
|
const query = appScopedQuery(appCode);
|
||||||
|
if (statTz) {
|
||||||
|
query.stat_tz = statTz;
|
||||||
|
}
|
||||||
|
if (startMs) {
|
||||||
|
query.start_ms = String(startMs);
|
||||||
|
}
|
||||||
|
if (endMs) {
|
||||||
|
query.end_ms = String(endMs);
|
||||||
|
}
|
||||||
|
if (statDay) {
|
||||||
|
query.stat_day = statDay;
|
||||||
|
}
|
||||||
|
if (countryId) {
|
||||||
|
query.country_id = String(countryId);
|
||||||
|
}
|
||||||
|
if (regionId && regionId !== "all") {
|
||||||
|
query.region_id = String(regionId);
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
query.user_id = String(userId);
|
||||||
|
}
|
||||||
|
if (source && source !== "all") {
|
||||||
|
query.source = String(source);
|
||||||
|
}
|
||||||
|
if (page) {
|
||||||
|
query.page = String(page);
|
||||||
|
}
|
||||||
|
if (pageSize) {
|
||||||
|
query.page_size = String(pageSize);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFilterOptions({ appCode } = {}) {
|
export async function fetchFilterOptions({ appCode } = {}) {
|
||||||
@ -77,6 +140,21 @@ export async function fetchFilterOptions({ appCode } = {}) {
|
|||||||
return normalizeFilterOptions({ apps, countries, regions });
|
return normalizeFilterOptions({ apps, countries, regions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSocialBiFilterOptions({ appCode } = {}) {
|
||||||
|
const [apps, operators] = await Promise.all([
|
||||||
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
||||||
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listTeamUsers, { team_id: SOCIAL_BI_OPERATION_TEAM_ID }), {
|
||||||
|
appCode,
|
||||||
|
query: { page_size: 200, status: "active" }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appOptions: normalizeSocialBiAppOptions(apps),
|
||||||
|
operatorOptions: normalizeSocialBiOperatorOptions(operators)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDatabiData(path, { appCode, query } = {}) {
|
async function fetchDatabiData(path, { appCode, query } = {}) {
|
||||||
const response = await fetch(buildURL(path, query), {
|
const response = await fetch(buildURL(path, query), {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@ -145,6 +223,12 @@ function normalizeAppCode(value) {
|
|||||||
return String(value || "").trim().toLowerCase();
|
return String(value || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appScopedQuery(appCode) {
|
||||||
|
const normalized = normalizeAppCode(appCode);
|
||||||
|
// “全部”使用空 appCode,让请求层省略 X-App-Code 和 app_code,避免把 all 当成真实 App 传给后端。
|
||||||
|
return normalized ? { app_code: normalized } : {};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFilterOptions({ apps, countries, regions }) {
|
function normalizeFilterOptions({ apps, countries, regions }) {
|
||||||
const regionItems = normalizeList(regions);
|
const regionItems = normalizeList(regions);
|
||||||
const regionOptions = [
|
const regionOptions = [
|
||||||
@ -169,7 +253,7 @@ function normalizeFilterOptions({ apps, countries, regions }) {
|
|||||||
.map((country) => normalizeCountryOption(country, countryRegionIds))
|
.map((country) => normalizeCountryOption(country, countryRegionIds))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
];
|
];
|
||||||
const appOptions = normalizeList(apps)
|
const apiAppOptions = normalizeList(apps)
|
||||||
.map((app) => {
|
.map((app) => {
|
||||||
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
||||||
return {
|
return {
|
||||||
@ -179,6 +263,7 @@ function normalizeFilterOptions({ apps, countries, regions }) {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((app) => app.code);
|
.filter((app) => app.code);
|
||||||
|
const appOptions = mergeAppOptions([ALL_APP_OPTION, ...apiAppOptions, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appOptions,
|
appOptions,
|
||||||
@ -187,6 +272,65 @@ function normalizeFilterOptions({ apps, countries, regions }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSocialBiAppOptions(apps) {
|
||||||
|
const options = normalizeList(apps)
|
||||||
|
.map((app) => {
|
||||||
|
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
||||||
|
if (!code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: app.appName || app.app_name || app.name || code,
|
||||||
|
value: code
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const fixedOptions = FIXED_APP_OPTIONS.map((app) => ({ label: app.label, value: app.value }));
|
||||||
|
return [{ label: "全部 App", value: SOCIAL_BI_ALL_VALUE }, ...dedupeOptions([...options, ...fixedOptions])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSocialBiOperatorOptions(operators) {
|
||||||
|
const options = normalizeList(operators)
|
||||||
|
.map((user) => {
|
||||||
|
const id = user.id ?? user.userId ?? user.user_id ?? user.account;
|
||||||
|
const label = user.name || user.nickname || user.account || String(id || "");
|
||||||
|
if (!id || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
searchText: [label, user.account, user.teamName, user.team].filter(Boolean).join(" ").toLowerCase(),
|
||||||
|
value: String(id)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return [{ label: "全部人员", searchText: "all 全部人员", value: SOCIAL_BI_ALL_VALUE }, ...dedupeOptions(options)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeOptions(options) {
|
||||||
|
const seen = new Set();
|
||||||
|
return options.filter((option) => {
|
||||||
|
const key = String(option.value || option.label || "").trim().toLowerCase();
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAppOptions(options) {
|
||||||
|
const seen = new Set();
|
||||||
|
return options.filter((option) => {
|
||||||
|
const code = normalizeAppCode(option.code ?? option.value);
|
||||||
|
if (seen.has(code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(code);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeList(value) {
|
function normalizeList(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
115
databi/src/api.test.js
Normal file
115
databi/src/api.test.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { fetchFilterOptions, fetchSocialBiFilterOptions, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stores the last selected databi app including all apps", () => {
|
||||||
|
expect(getCurrentAppCode()).toBe("lalu");
|
||||||
|
|
||||||
|
expect(setCurrentAppCode("YUMI")).toBe("yumi");
|
||||||
|
expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe("yumi");
|
||||||
|
expect(getCurrentAppCode()).toBe("yumi");
|
||||||
|
|
||||||
|
expect(setCurrentAppCode("")).toBe("");
|
||||||
|
expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe("");
|
||||||
|
expect(getCurrentAppCode()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds fixed databi app options without losing API apps", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/v1/admin/apps")) {
|
||||||
|
return jsonResponse({ items: [{ appCode: "huwaa", appName: "Huwaa" }, { app_code: "lalu", name: "Lalu" }] });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/regions")) {
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/countries")) {
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
}
|
||||||
|
return jsonResponse(null, 404);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = await fetchFilterOptions({ appCode: "lalu" });
|
||||||
|
|
||||||
|
expect(options.appOptions).toEqual([
|
||||||
|
{ code: "", label: "全部", value: "" },
|
||||||
|
{ code: "huwaa", label: "Huwaa", value: "huwaa" },
|
||||||
|
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provides static databi app options before filter API loads", () => {
|
||||||
|
expect(getDefaultAppOptions()).toEqual([
|
||||||
|
{ code: "", label: "全部", value: "" },
|
||||||
|
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads social BI apps and operation users from admin APIs", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/v1/admin/apps")) {
|
||||||
|
return jsonResponse({ items: [{ appCode: "huwaa", appName: "Huwaa" }] });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/teams/2/users")) {
|
||||||
|
return jsonResponse({ items: [{ account: "ava", id: 7, name: "Ava Chen", teamName: "运营一部" }] });
|
||||||
|
}
|
||||||
|
return jsonResponse(null, 404);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = await fetchSocialBiFilterOptions({ appCode: "lalu" });
|
||||||
|
|
||||||
|
expect(options.appOptions).toEqual([
|
||||||
|
{ label: "全部 App", value: "all" },
|
||||||
|
{ label: "Huwaa", value: "huwaa" },
|
||||||
|
{ label: "Aslan", value: "aslan" },
|
||||||
|
{ label: "Yumi", value: "yumi" }
|
||||||
|
]);
|
||||||
|
expect(options.operatorOptions).toEqual([
|
||||||
|
{ label: "全部人员", searchText: "all 全部人员", value: "all" },
|
||||||
|
{ label: "Ava Chen", searchText: "ava chen ava 运营一部", value: "7" }
|
||||||
|
]);
|
||||||
|
expect(String(fetch.mock.calls[1][0])).toContain("/api/v1/teams/2/users?");
|
||||||
|
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits app scope headers and query params for all databi apps", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));
|
||||||
|
|
||||||
|
await fetchStatisticsOverview({
|
||||||
|
appCode: "",
|
||||||
|
countryId: 0,
|
||||||
|
endMs: 2,
|
||||||
|
regionId: "all",
|
||||||
|
seriesEndMs: 2,
|
||||||
|
seriesStartMs: 1,
|
||||||
|
startMs: 1,
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = fetch.mock.calls[0];
|
||||||
|
const requestURL = new URL(String(url));
|
||||||
|
expect(requestURL.searchParams.has("app_code")).toBe(false);
|
||||||
|
expect(init.headers["X-App-Code"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(data, status = 200) {
|
||||||
|
return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -34,3 +34,34 @@ test("switches to end time after selecting a start date", async () => {
|
|||||||
|
|
||||||
expect(within(dialog).getByRole("tab", { name: /结束时间/ })).toHaveAttribute("aria-selected", "true");
|
expect(within(dialog).getByRole("tab", { name: /结束时间/ })).toHaveAttribute("aria-selected", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selects all apps from app filter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAppChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DatabiHeader
|
||||||
|
appCode="lalu"
|
||||||
|
appOptions={[
|
||||||
|
{ code: "", label: "全部", value: "" },
|
||||||
|
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||||
|
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||||
|
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||||
|
]}
|
||||||
|
countryId={0}
|
||||||
|
onAppChange={onAppChange}
|
||||||
|
onCountryChange={noop}
|
||||||
|
onRangeChange={noop}
|
||||||
|
onRegionChange={noop}
|
||||||
|
onTimeZoneChange={noop}
|
||||||
|
range={{ end: "2026-06-08", endTime: "23:59:59", start: "2026-06-08", startTime: "00:00:00" }}
|
||||||
|
regionId="all"
|
||||||
|
timeZone="UTC"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /App\s*Lalu/ }));
|
||||||
|
await user.click(within(screen.getByRole("listbox")).getByRole("option", { name: "全部" }));
|
||||||
|
|
||||||
|
expect(onAppChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
|
||||||
import { EChart } from "../charts/EChart.jsx";
|
import { EChart } from "../charts/EChart.jsx";
|
||||||
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
||||||
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
|
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
|
||||||
@ -14,6 +15,16 @@ const reportTabs = [
|
|||||||
{ key: "robot", label: "机器人送礼" }
|
{ key: "robot", label: "机器人送礼" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const platformGrantRecordSourceOptions = [
|
||||||
|
{ label: "全部来源", value: "all" },
|
||||||
|
{ label: "任务奖励", value: "WalletTaskRewardCredited" },
|
||||||
|
{ label: "房间流水奖励", value: "WalletRoomTurnoverRewardCredited" },
|
||||||
|
{ label: "邀请活动奖励", value: "WalletInviteActivityRewardCredited" },
|
||||||
|
{ label: "代理开通奖励", value: "WalletAgencyOpeningRewardCredited" },
|
||||||
|
{ label: "资源发放", value: "WalletBalanceChanged" },
|
||||||
|
{ label: "平台发放", value: "WalletPlatformGrantCoinCredited" }
|
||||||
|
];
|
||||||
|
|
||||||
const countryColumns = [
|
const countryColumns = [
|
||||||
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false, stickyLeft: true },
|
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false, stickyLeft: true },
|
||||||
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
|
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
|
||||||
@ -32,9 +43,11 @@ const countryColumns = [
|
|||||||
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
|
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
|
||||||
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
|
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
|
||||||
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
|
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
|
||||||
{ key: "coin_total", label: "金币数量(总和)", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
|
{ key: "coin_total", label: "用户金币数", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
|
||||||
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
|
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
|
||||||
{ key: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false },
|
{ key: "output_coin", label: "产出金币", render: (row) => formatOptionalCoin(row.output_coin), sortable: false },
|
||||||
|
{ key: "consume_output_ratio", label: "消耗产出比", render: (row) => <ConsumeOutputCell row={row} />, sortable: false },
|
||||||
|
{ key: "platform_grant_coin", label: "平台发放金币", render: (row, context) => <PlatformGrantCoinCell context={context} row={row} />, sortable: false },
|
||||||
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
|
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
|
||||||
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
|
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
|
||||||
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
|
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
|
||||||
@ -88,7 +101,7 @@ const metricColumns = [
|
|||||||
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
|
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
export function ReportOverview({ grantDrilldownQuery, loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||||||
const [activeTab, setActiveTab] = useState("country");
|
const [activeTab, setActiveTab] = useState("country");
|
||||||
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
||||||
|
|
||||||
@ -116,7 +129,7 @@ export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLu
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="report-tab-panel" role="tabpanel">
|
<div className="report-tab-panel" role="tabpanel">
|
||||||
{activeTab === "country" ? <CountryReport loading={loading} rows={model.reportCountryRows} /> : null}
|
{activeTab === "country" ? <CountryReport grantDrilldownQuery={grantDrilldownQuery} loading={loading} rows={model.reportCountryRows} /> : null}
|
||||||
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
|
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
|
||||||
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
|
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
|
||||||
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
|
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
|
||||||
@ -171,8 +184,11 @@ function ReportMetricCard({ card, loading, onOpenTrend }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountryReport({ loading, rows }) {
|
function CountryReport({ grantDrilldownQuery, loading, rows }) {
|
||||||
const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
|
const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
|
||||||
|
const [grantCountryModal, setGrantCountryModal] = useState(null);
|
||||||
|
const [grantUsersModal, setGrantUsersModal] = useState(null);
|
||||||
|
const [grantRecordsModal, setGrantRecordsModal] = useState(null);
|
||||||
const toggleRow = useCallback((rowId) => {
|
const toggleRow = useCallback((rowId) => {
|
||||||
setExpandedRowIds((current) => {
|
setExpandedRowIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@ -195,17 +211,66 @@ function CountryReport({ loading, rows }) {
|
|||||||
onToggle: () => toggleRow(row.row_id)
|
onToggle: () => toggleRow(row.row_id)
|
||||||
};
|
};
|
||||||
}), [expandedRowIds, rows, toggleRow]);
|
}), [expandedRowIds, rows, toggleRow]);
|
||||||
|
const openGrantUsers = useCallback((row) => {
|
||||||
|
setGrantCountryModal(null);
|
||||||
|
setGrantRecordsModal(null);
|
||||||
|
setGrantUsersModal({
|
||||||
|
countryLabel: row.country || "国家",
|
||||||
|
query: buildPlatformGrantQuery(grantDrilldownQuery, row)
|
||||||
|
});
|
||||||
|
}, [grantDrilldownQuery]);
|
||||||
|
const openGrantCountries = useCallback((row) => {
|
||||||
|
const countryRows = platformGrantCountryRows(row, rows);
|
||||||
|
if (!countryRows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGrantRecordsModal(null);
|
||||||
|
setGrantUsersModal(null);
|
||||||
|
setGrantCountryModal({
|
||||||
|
rows: countryRows,
|
||||||
|
title: row.row_type === "date_total" ? `${row.date_label} 平台发放金币` : "平台发放金币"
|
||||||
|
});
|
||||||
|
}, [rows]);
|
||||||
|
const grantContext = useMemo(() => ({
|
||||||
|
onOpenPlatformGrantCountries: openGrantCountries,
|
||||||
|
onOpenPlatformGrantUsers: openGrantUsers
|
||||||
|
}), [openGrantCountries, openGrantUsers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportPanel>
|
<ReportPanel>
|
||||||
<ReportTable
|
<ReportTable
|
||||||
columns={countryColumns}
|
columns={countryColumns}
|
||||||
|
context={grantContext}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
preserveOrder
|
preserveOrder
|
||||||
renderExpandedRow={(row) => <CountryDetailTable dateLabel={row.date_label} rows={row.children || []} />}
|
renderExpandedRow={(row) => <CountryDetailTable context={grantContext} dateLabel={row.date_label} rows={row.children || []} />}
|
||||||
rows={tableRows}
|
rows={tableRows}
|
||||||
wide
|
wide
|
||||||
/>
|
/>
|
||||||
|
{grantCountryModal ? (
|
||||||
|
<PlatformGrantCountryModal
|
||||||
|
modal={grantCountryModal}
|
||||||
|
onClose={() => setGrantCountryModal(null)}
|
||||||
|
onOpenUsers={openGrantUsers}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{grantUsersModal ? (
|
||||||
|
<PlatformGrantUsersModal
|
||||||
|
modal={grantUsersModal}
|
||||||
|
onClose={() => setGrantUsersModal(null)}
|
||||||
|
onOpenRecords={(userRow) => setGrantRecordsModal({
|
||||||
|
countryLabel: grantUsersModal.countryLabel,
|
||||||
|
query: { ...grantUsersModal.query, userId: userRow.user_id },
|
||||||
|
user: userRow
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{grantRecordsModal ? (
|
||||||
|
<PlatformGrantRecordsModal
|
||||||
|
modal={grantRecordsModal}
|
||||||
|
onClose={() => setGrantRecordsModal(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -290,7 +355,7 @@ function ReportMetricTable({ loading, rows }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
|
function ReportTable({ columns, context, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
|
||||||
const sortableDefault = defaultSort || firstSortableColumn(columns);
|
const sortableDefault = defaultSort || firstSortableColumn(columns);
|
||||||
const [sortState, setSortState] = useState(sortableDefault);
|
const [sortState, setSortState] = useState(sortableDefault);
|
||||||
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
|
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
|
||||||
@ -360,7 +425,7 @@ function ReportTable({ columns, defaultSort, loading, preserveOrder = false, ren
|
|||||||
].filter(Boolean).join(" ")}
|
].filter(Boolean).join(" ")}
|
||||||
key={column.key}
|
key={column.key}
|
||||||
>
|
>
|
||||||
{column.render ? column.render(row) : row[column.key]}
|
{column.render ? column.render(row, context) : row[column.key]}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -378,7 +443,7 @@ function ReportTable({ columns, defaultSort, loading, preserveOrder = false, ren
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CountryDetailTable({ dateLabel, rows }) {
|
function CountryDetailTable({ context, dateLabel, rows }) {
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
return <div className="report-subtable-empty">当前无数据</div>;
|
return <div className="report-subtable-empty">当前无数据</div>;
|
||||||
}
|
}
|
||||||
@ -398,7 +463,7 @@ function CountryDetailTable({ dateLabel, rows }) {
|
|||||||
<tr key={reportRowKey(row, index)}>
|
<tr key={reportRowKey(row, index)}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
{countryDetailColumns.map((column) => (
|
{countryDetailColumns.map((column) => (
|
||||||
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
|
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row, context) : row[column.key]}</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -408,6 +473,269 @@ function CountryDetailTable({ dateLabel, rows }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformGrantCoinCell({ context, row }) {
|
||||||
|
const value = Number(row.platform_grant_coin || 0);
|
||||||
|
const clickable = value > 0 && (row.row_type === "country" ? context?.onOpenPlatformGrantUsers : context?.onOpenPlatformGrantCountries);
|
||||||
|
if (!clickable) {
|
||||||
|
return formatOptionalCoin(row.platform_grant_coin);
|
||||||
|
}
|
||||||
|
const label = row.row_type === "country"
|
||||||
|
? `查看 ${row.country || "国家"} 平台发放金币用户明细`
|
||||||
|
: `查看 ${row.date_label || "全部"} 平台发放金币国家明细`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className="report-link-button"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (row.row_type === "country") {
|
||||||
|
context.onOpenPlatformGrantUsers(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.onOpenPlatformGrantCountries(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCoin(value)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformGrantCountryModal({ modal, onClose, onOpenUsers }) {
|
||||||
|
return (
|
||||||
|
<ReportDialog title={modal.title} onClose={onClose}>
|
||||||
|
<div className="report-modal-table-wrap">
|
||||||
|
<table className="report-modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>国家</th>
|
||||||
|
<th>平台发放金币</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modal.rows.length ? modal.rows.map((row) => (
|
||||||
|
<tr key={reportRowKey(row, row.country_id || row.country)}>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
aria-label={`查看 ${row.country || "国家"} 平台发放金币用户明细`}
|
||||||
|
className="report-link-button report-link-button--left"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenUsers(row)}
|
||||||
|
>
|
||||||
|
<CountryCell row={row} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{formatOptionalCoin(row.platform_grant_coin)}</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr className="report-table-empty-row"><td colSpan={2}>当前无数据</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ReportDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformGrantUsersModal({ modal, onClose, onOpenRecords }) {
|
||||||
|
const pageSize = 20;
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
fetchPlatformGrantUsers({ ...modal.query, page, pageSize })
|
||||||
|
.then((nextData) => {
|
||||||
|
if (active) {
|
||||||
|
setData(nextData || {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || "请求失败");
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [modal.query, page]);
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
const total = Number(data?.total || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportDialog title={`${modal.countryLabel} 平台发放金币用户`} onClose={onClose}>
|
||||||
|
<div className="report-modal-table-wrap">
|
||||||
|
<table className="report-modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>平台发放金币</th>
|
||||||
|
<th>记录数</th>
|
||||||
|
<th>最近发放时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
|
||||||
|
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
|
||||||
|
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
|
||||||
|
{!loading && !error ? items.map((item) => {
|
||||||
|
const openRecords = () => onOpenRecords(item);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
aria-label={`查看 ${platformGrantUserLabel(item)} 平台发放金币来源明细`}
|
||||||
|
className="report-clickable-row"
|
||||||
|
key={item.user_id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={openRecords}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openRecords();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className="report-user-cell">
|
||||||
|
{item.avatar ? <img alt="" src={item.avatar} /> : null}
|
||||||
|
<span>
|
||||||
|
<b>{platformGrantUserLabel(item)}</b>
|
||||||
|
<small>{platformGrantUserMeta(item)}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatOptionalCoin(item.total_coin)}</td>
|
||||||
|
<td>{formatOptionalCoin(item.record_count)}</td>
|
||||||
|
<td>{formatDateTime(item.last_granted_at_ms)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||||||
|
</ReportDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformGrantRecordsModal({ modal, onClose }) {
|
||||||
|
const pageSize = 20;
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [source, setSource] = useState("all");
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
fetchPlatformGrantRecords({ ...modal.query, page, pageSize, source })
|
||||||
|
.then((nextData) => {
|
||||||
|
if (active) {
|
||||||
|
setData(nextData || {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || "请求失败");
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [modal.query, page, source]);
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
const total = Number(data?.total || 0);
|
||||||
|
const selectSource = (event) => {
|
||||||
|
setSource(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportDialog title={`${platformGrantUserLabel(modal.user)} 平台发放金币来源`} onClose={onClose}>
|
||||||
|
<div className="report-modal-toolbar">
|
||||||
|
<label className="report-source-filter">
|
||||||
|
<span>来源</span>
|
||||||
|
<select aria-label="来源筛选" value={source} onChange={selectSource}>
|
||||||
|
{platformGrantRecordSourceOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="report-modal-table-wrap">
|
||||||
|
<table className="report-modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>金币</th>
|
||||||
|
<th>发放时间</th>
|
||||||
|
<th>事件 ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
|
||||||
|
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
|
||||||
|
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
|
||||||
|
{!loading && !error ? items.map((item) => (
|
||||||
|
<tr key={item.event_id}>
|
||||||
|
<td>{platformGrantSourceLabel(item.event_type)}</td>
|
||||||
|
<td>{formatOptionalCoin(item.amount)}</td>
|
||||||
|
<td>{formatDateTime(item.occurred_at_ms)}</td>
|
||||||
|
<td><span className="report-event-id">{item.event_id || "--"}</span></td>
|
||||||
|
</tr>
|
||||||
|
)) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||||||
|
</ReportDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDialog({ children, onClose, title }) {
|
||||||
|
return (
|
||||||
|
<div className="report-modal-backdrop">
|
||||||
|
<section aria-label={title} aria-modal="true" className="report-modal" role="dialog">
|
||||||
|
<header className="report-modal-head">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="report-modal-close" type="button" onClick={onClose}>关闭</button>
|
||||||
|
</header>
|
||||||
|
<div className="report-modal-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportPagination({ onPageChange, page, pageSize, total }) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<div className="report-pagination">
|
||||||
|
<span>共 {formatCoin(total)} 条</span>
|
||||||
|
<div>
|
||||||
|
<button disabled={page <= 1} type="button" onClick={() => onPageChange(page - 1)}>上一页</button>
|
||||||
|
<b>{page} / {totalPages}</b>
|
||||||
|
<button disabled={page >= totalPages} type="button" onClick={() => onPageChange(page + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CountryCell({ row }) {
|
function CountryCell({ row }) {
|
||||||
return (
|
return (
|
||||||
<span className="report-country-cell">
|
<span className="report-country-cell">
|
||||||
@ -444,6 +772,18 @@ function FlowRateCell({ rate, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConsumeOutputCell({ row }) {
|
||||||
|
if (isMissing(row.consume_output_ratio) && isMissing(row.consume_output_delta)) {
|
||||||
|
return <span className="report-placeholder">--</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="report-pair-cell">
|
||||||
|
<b>{formatOptionalPercent(row.consume_output_ratio)}</b>
|
||||||
|
<small>差额 {formatOptionalCoin(row.consume_output_delta)}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TrendValue({ value }) {
|
function TrendValue({ value }) {
|
||||||
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
|
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
|
||||||
}
|
}
|
||||||
@ -575,6 +915,110 @@ function compareValues(leftValue, rightValue, type) {
|
|||||||
return leftNumber - rightNumber;
|
return leftNumber - rightNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPlatformGrantQuery(baseQuery = {}, row = {}) {
|
||||||
|
const countryId = Number(row.country_id ?? row.countryId ?? baseQuery.countryId ?? 0);
|
||||||
|
const regionId = row.region_id ?? row.regionId ?? baseQuery.regionId;
|
||||||
|
return {
|
||||||
|
appCode: baseQuery.appCode,
|
||||||
|
countryId,
|
||||||
|
endMs: baseQuery.endMs,
|
||||||
|
regionId,
|
||||||
|
startMs: baseQuery.startMs,
|
||||||
|
statDay: row.stat_day || row.statDay || "",
|
||||||
|
statTz: baseQuery.statTz
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformGrantCountryRows(row, rows) {
|
||||||
|
const sourceRows = row.row_type === "date_total" && Array.isArray(row.children) && row.children.length
|
||||||
|
? row.children
|
||||||
|
: collectPlatformGrantCountryRows(rows);
|
||||||
|
return aggregatePlatformGrantCountryRows(sourceRows)
|
||||||
|
.filter((item) => Number(item.platform_grant_coin || 0) > 0)
|
||||||
|
.sort((left, right) => Number(right.platform_grant_coin || 0) - Number(left.platform_grant_coin || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPlatformGrantCountryRows(rows) {
|
||||||
|
const out = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.row_type === "country") {
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
if (Array.isArray(row.children)) {
|
||||||
|
out.push(...collectPlatformGrantCountryRows(row.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregatePlatformGrantCountryRows(rows) {
|
||||||
|
const grouped = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const key = [row.country_id ?? row.country, row.region_id ?? ""].join(":");
|
||||||
|
const current = grouped.get(key);
|
||||||
|
if (!current) {
|
||||||
|
grouped.set(key, { ...row, platform_grant_coin: Number(row.platform_grant_coin || 0) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStatDay = current.stat_day && row.stat_day && current.stat_day === row.stat_day ? current.stat_day : "";
|
||||||
|
grouped.set(key, {
|
||||||
|
...current,
|
||||||
|
platform_grant_coin: Number(current.platform_grant_coin || 0) + Number(row.platform_grant_coin || 0),
|
||||||
|
stat_day: nextStatDay
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(grouped.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformGrantUserLabel(item = {}) {
|
||||||
|
return item.user?.display_user_id || item.display_user_id || item.user?.username || item.username || item.nickname || item.user_id || "用户";
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformGrantUserMeta(item = {}) {
|
||||||
|
return [item.user?.username || item.username || item.nickname, item.user_id]
|
||||||
|
.filter((value, index, values) => value && values.indexOf(value) === index)
|
||||||
|
.join(" · ") || "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformGrantSourceLabel(value) {
|
||||||
|
switch (String(value || "").trim()) {
|
||||||
|
case "WalletLuckyGiftRewardCredited":
|
||||||
|
return "幸运礼物返奖";
|
||||||
|
case "WalletTaskRewardCredited":
|
||||||
|
return "任务奖励";
|
||||||
|
case "WalletWheelRewardCredited":
|
||||||
|
return "转盘奖励";
|
||||||
|
case "WalletRoomTurnoverRewardCredited":
|
||||||
|
return "房间流水奖励";
|
||||||
|
case "WalletInviteActivityRewardCredited":
|
||||||
|
return "邀请活动奖励";
|
||||||
|
case "WalletAgencyOpeningRewardCredited":
|
||||||
|
return "代理开通奖励";
|
||||||
|
case "WalletBalanceChanged":
|
||||||
|
return "资源发放";
|
||||||
|
case "WalletPlatformGrantCoinCredited":
|
||||||
|
return "平台发放";
|
||||||
|
default:
|
||||||
|
return value || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
const ms = Number(value || 0);
|
||||||
|
if (!ms) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return new Date(ms).toLocaleString("zh-CN", {
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatSignedPercent(value) {
|
function formatSignedPercent(value) {
|
||||||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||||
return "--";
|
return "--";
|
||||||
|
|||||||
@ -1,9 +1,20 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { expect, test } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
|
||||||
import { createDashboardModel } from "../data/createDashboardModel.js";
|
import { createDashboardModel } from "../data/createDashboardModel.js";
|
||||||
import { ReportOverview } from "./ReportOverview.jsx";
|
import { ReportOverview } from "./ReportOverview.jsx";
|
||||||
|
|
||||||
|
vi.mock("../api.js", () => ({
|
||||||
|
fetchPlatformGrantRecords: vi.fn(),
|
||||||
|
fetchPlatformGrantUsers: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchPlatformGrantRecords).mockReset();
|
||||||
|
vi.mocked(fetchPlatformGrantUsers).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
test("shows daily totals first and expands daily country details on demand", async () => {
|
test("shows daily totals first and expands daily country details on demand", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel({
|
||||||
@ -48,6 +59,10 @@ test("shows daily totals first and expands daily country details on demand", asy
|
|||||||
|
|
||||||
const stickyDateHeader = container.querySelector(".report-table--wide th.is-sticky-left");
|
const stickyDateHeader = container.querySelector(".report-table--wide th.is-sticky-left");
|
||||||
expect(stickyDateHeader).toHaveTextContent("日期");
|
expect(stickyDateHeader).toHaveTextContent("日期");
|
||||||
|
expect(screen.getByRole("columnheader", { name: "用户金币数" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "产出金币" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "消耗产出比" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "金币数量(总和)" })).not.toBeInTheDocument();
|
||||||
expect(container.querySelector(".report-table--wide td.is-sticky-left .report-date-cell")).toBeTruthy();
|
expect(container.querySelector(".report-table--wide td.is-sticky-left .report-date-cell")).toBeTruthy();
|
||||||
|
|
||||||
const june5Row = screen.getByRole("button", { name: "展开 2026-06-05 国家明细" });
|
const june5Row = screen.getByRole("button", { name: "展开 2026-06-05 国家明细" });
|
||||||
@ -67,3 +82,94 @@ test("shows daily totals first and expands daily country details on demand", asy
|
|||||||
expect(june5Row).toHaveAttribute("aria-expanded", "false");
|
expect(june5Row).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
|
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("drills platform grant coin from total to country users and user records", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(fetchPlatformGrantUsers).mockResolvedValue({
|
||||||
|
items: [{
|
||||||
|
display_user_id: "163001",
|
||||||
|
last_granted_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30),
|
||||||
|
record_count: 2,
|
||||||
|
total_coin: 55,
|
||||||
|
user_id: "7001",
|
||||||
|
username: "Ana"
|
||||||
|
}],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1
|
||||||
|
});
|
||||||
|
const taskRecord = { amount: 33, event_id: "grant:task:7001", event_type: "WalletTaskRewardCredited", occurred_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30), user_id: "7001" };
|
||||||
|
const resourceRecord = { amount: 22, event_id: "grant:resource:7001", event_type: "WalletBalanceChanged", occurred_at_ms: Date.UTC(2026, 5, 5, 9, 20, 30), user_id: "7001" };
|
||||||
|
vi.mocked(fetchPlatformGrantRecords).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
taskRecord,
|
||||||
|
resourceRecord
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 2
|
||||||
|
});
|
||||||
|
const model = createDashboardModel({
|
||||||
|
country_breakdown: [
|
||||||
|
{ country: "巴西", country_id: 86, platform_grant_coin: 55, region_id: 10 },
|
||||||
|
{ country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20 }
|
||||||
|
],
|
||||||
|
daily_country_breakdown: [
|
||||||
|
{ country: "巴西", country_id: 86, platform_grant_coin: 33, region_id: 10, stat_day: "2026-06-05" },
|
||||||
|
{ country: "巴西", country_id: 86, platform_grant_coin: 22, region_id: 10, stat_day: "2026-06-06" },
|
||||||
|
{ country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20, stat_day: "2026-06-06" }
|
||||||
|
],
|
||||||
|
daily_series: [
|
||||||
|
{ platform_grant_coin: 33, stat_day: "2026-06-05" },
|
||||||
|
{ platform_grant_coin: 34, stat_day: "2026-06-06" }
|
||||||
|
],
|
||||||
|
platform_grant_coin: 67
|
||||||
|
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReportOverview
|
||||||
|
grantDrilldownQuery={{
|
||||||
|
appCode: "lalu",
|
||||||
|
countryId: 0,
|
||||||
|
endMs: Date.UTC(2026, 5, 7),
|
||||||
|
regionId: "all",
|
||||||
|
startMs: Date.UTC(2026, 5, 5),
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
}}
|
||||||
|
model={model}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看 全部 平台发放金币国家明细" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "平台发放金币" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看 巴西 平台发放金币用户明细" }));
|
||||||
|
await waitFor(() => expect(fetchPlatformGrantUsers).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
appCode: "lalu",
|
||||||
|
countryId: 86,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
regionId: 10,
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
})));
|
||||||
|
expect(await screen.findByRole("button", { name: "查看 163001 平台发放金币来源明细" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看 163001 平台发放金币来源明细" }));
|
||||||
|
await waitFor(() => expect(fetchPlatformGrantRecords).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
countryId: 86,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
regionId: 10,
|
||||||
|
userId: "7001"
|
||||||
|
})));
|
||||||
|
expect(await screen.findByRole("cell", { name: "任务奖励" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("cell", { name: "资源发放" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByLabelText("来源筛选"), "WalletTaskRewardCredited");
|
||||||
|
await waitFor(() => expect(fetchPlatformGrantRecords).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
source: "WalletTaskRewardCredited",
|
||||||
|
userId: "7001"
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|||||||
1230
databi/src/components/SocialBiDashboard.jsx
Normal file
1230
databi/src/components/SocialBiDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,9 +22,11 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
const realRoomRobotNormalGiftCoin = readNumber(source, "real_room_robot_normal_gift_coin", "realRoomRobotNormalGiftCoin");
|
const realRoomRobotNormalGiftCoin = readNumber(source, "real_room_robot_normal_gift_coin", "realRoomRobotNormalGiftCoin");
|
||||||
const realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
const realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
||||||
const realRoomRobotSuperGiftCoin = readNumber(source, "real_room_robot_super_gift_coin", "realRoomRobotSuperGiftCoin");
|
const realRoomRobotSuperGiftCoin = readNumber(source, "real_room_robot_super_gift_coin", "realRoomRobotSuperGiftCoin");
|
||||||
const realRoomRobotTotalGiftCoin = readNumber(source, "real_room_robot_total_gift_coin", "realRoomRobotTotalGiftCoin") || realRoomRobotNormalGiftCoin + realRoomRobotLuckyGiftCoin + realRoomRobotSuperGiftCoin;
|
const realRoomRobotTotalGiftCoin =
|
||||||
|
readNumber(source, "real_room_robot_total_gift_coin", "realRoomRobotTotalGiftCoin") || realRoomRobotNormalGiftCoin + realRoomRobotLuckyGiftCoin + realRoomRobotSuperGiftCoin;
|
||||||
const realRoomRobotGiftRoomCount = readNumber(source, "real_room_robot_gift_room_count", "realRoomRobotGiftRoomCount");
|
const realRoomRobotGiftRoomCount = readNumber(source, "real_room_robot_gift_room_count", "realRoomRobotGiftRoomCount");
|
||||||
const realRoomRobotAvgRoomGiftCoin = readNumber(source, "real_room_robot_avg_room_gift_coin", "realRoomRobotAvgRoomGiftCoin") || Math.round(ratio(realRoomRobotTotalGiftCoin, realRoomRobotGiftRoomCount));
|
const realRoomRobotAvgRoomGiftCoin =
|
||||||
|
readNumber(source, "real_room_robot_avg_room_gift_coin", "realRoomRobotAvgRoomGiftCoin") || Math.round(ratio(realRoomRobotTotalGiftCoin, realRoomRobotGiftRoomCount));
|
||||||
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
const luckyGiftPools = normalizeLuckyGiftPools(source);
|
||||||
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
||||||
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
|
||||||
@ -38,73 +40,191 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
||||||
const payoutDistribution = normalizeDistribution(source);
|
const payoutDistribution = normalizeDistribution(source);
|
||||||
const gameRanking = normalizeGameRanking(source);
|
const gameRanking = normalizeGameRanking(source);
|
||||||
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
|
const reportCountryRows = buildReportCountryRows({
|
||||||
|
countryBreakdown,
|
||||||
|
dailyCountryBreakdown,
|
||||||
|
dailySeries,
|
||||||
|
range,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appCode,
|
appCode,
|
||||||
businessKpis: [
|
businessKpis: [
|
||||||
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
|
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, {
|
||||||
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "coin") }),
|
trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin"),
|
||||||
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
|
}),
|
||||||
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
|
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, {
|
||||||
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
|
trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "coin"),
|
||||||
coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin") }),
|
}),
|
||||||
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
|
coinMetric(
|
||||||
|
"幸运礼物流水",
|
||||||
|
luckyGiftTurnover,
|
||||||
|
readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"),
|
||||||
|
comparisonCaption,
|
||||||
|
{
|
||||||
|
detailKey: "luckyGiftPools",
|
||||||
|
trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, {
|
||||||
|
detailKey: "luckyGiftPools",
|
||||||
|
trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin"),
|
||||||
|
}),
|
||||||
|
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, {
|
||||||
|
detailKey: "luckyGiftPools",
|
||||||
|
trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin"),
|
||||||
|
}),
|
||||||
|
coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, {
|
||||||
|
trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin"),
|
||||||
|
}),
|
||||||
|
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") }),
|
||||||
],
|
],
|
||||||
countryBreakdown,
|
countryBreakdown,
|
||||||
dailyCountryBreakdown,
|
dailyCountryBreakdown,
|
||||||
funnel: [
|
funnel: [
|
||||||
{ name: "活跃用户", rate: 1, value: activeUsers },
|
{ name: "活跃用户", rate: 1, value: activeUsers },
|
||||||
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
||||||
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers }
|
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers },
|
||||||
],
|
],
|
||||||
gameMetrics: [
|
gameMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
|
{
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) },
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")),
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) },
|
label: "流水",
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) }
|
value: formatWholeMoney(source.game_turnover),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")),
|
||||||
|
label: "派奖",
|
||||||
|
value: formatWholeMoney(source.game_payout),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")),
|
||||||
|
label: "退款",
|
||||||
|
value: formatWholeMoney(source.game_refund),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")),
|
||||||
|
label: "利润",
|
||||||
|
value: formatWholeMoney(source.game_profit),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
gameRanking,
|
gameRanking,
|
||||||
giftRanking: normalizeGiftRanking(source),
|
giftRanking: normalizeGiftRanking(source),
|
||||||
kpis: [
|
kpis: [
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"), trendFormat: "money", value: formatMoneyFull(recharge) },
|
{
|
||||||
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"),
|
||||||
|
icon: CoinIcon,
|
||||||
|
label: "总充值",
|
||||||
|
trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"),
|
||||||
|
trendFormat: "money",
|
||||||
|
value: formatMoneyFull(recharge),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: UserPlusIcon,
|
icon: UserPlusIcon,
|
||||||
label: "用户充值 / 新用户充值",
|
label: "用户充值 / 新用户充值",
|
||||||
subMetrics: [
|
subMetrics: [
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"), label: "用户充值", value: formatMoneyFull(userRecharge) },
|
{
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), label: "新用户充值", value: formatMoneyFull(newUserRecharge) }
|
caption: comparisonCaption,
|
||||||
],
|
delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"),
|
||||||
trend: [
|
label: "用户充值",
|
||||||
...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"),
|
value: formatMoneyFull(userRecharge),
|
||||||
...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money")
|
},
|
||||||
],
|
{
|
||||||
trendFormat: "money"
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"),
|
||||||
|
label: "新用户充值",
|
||||||
|
value: formatMoneyFull(newUserRecharge),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trend: [...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"), ...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money")],
|
||||||
|
trendFormat: "money",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"),
|
||||||
|
icon: UserPlusIcon,
|
||||||
|
label: "新增用户数",
|
||||||
|
trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"),
|
||||||
|
trendFormat: "number",
|
||||||
|
value: formatNumber(newUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"),
|
||||||
|
icon: ActiveUsersIcon,
|
||||||
|
label: "活跃用户",
|
||||||
|
trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"),
|
||||||
|
trendFormat: "number",
|
||||||
|
value: formatNumber(activeUsers),
|
||||||
},
|
},
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"), icon: UserPlusIcon, label: "新增用户数", trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"), trendFormat: "number", value: formatNumber(newUsers) },
|
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"), trendFormat: "number", value: formatNumber(activeUsers) },
|
|
||||||
{
|
{
|
||||||
icon: CrownIcon,
|
icon: CrownIcon,
|
||||||
label: "付费用户 / 新增付费用户",
|
label: "付费用户 / 新增付费用户",
|
||||||
subMetrics: [
|
subMetrics: [
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), label: "付费用户", value: formatNumber(paidUsers) },
|
{
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"), label: "新增付费用户", value: formatNumber(newPaidUsers) }
|
caption: comparisonCaption,
|
||||||
],
|
delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"),
|
||||||
trend: [
|
label: "付费用户",
|
||||||
...metricTrend(dailySeries, "paid_users", "付费用户", "number"),
|
value: formatNumber(paidUsers),
|
||||||
...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number")
|
},
|
||||||
],
|
{
|
||||||
trendFormat: "number"
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"),
|
||||||
|
label: "新增付费用户",
|
||||||
|
value: formatNumber(newPaidUsers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trend: [...metricTrend(dailySeries, "paid_users", "付费用户", "number"), ...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number")],
|
||||||
|
trendFormat: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"),
|
||||||
|
deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"),
|
||||||
|
icon: TrendIcon,
|
||||||
|
label: "ARPU",
|
||||||
|
trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"),
|
||||||
|
trendFormat: "money",
|
||||||
|
value: formatMoney(arpu),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: comparisonCaption,
|
||||||
|
delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"),
|
||||||
|
icon: StarUserIcon,
|
||||||
|
label: "ARPPU",
|
||||||
|
trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"),
|
||||||
|
trendFormat: "money",
|
||||||
|
value: formatMoney(arppu),
|
||||||
},
|
},
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"), trendFormat: "money", value: formatMoney(arpu) },
|
|
||||||
{ caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"), trendFormat: "money", value: formatMoney(arppu) }
|
|
||||||
],
|
],
|
||||||
luckyMetrics: [
|
luckyMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
|
{
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) },
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")),
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) },
|
label: "流水",
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
|
value: formatWholeMoney(luckyGiftTurnover),
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")),
|
||||||
|
label: "返奖",
|
||||||
|
value: formatWholeMoney(luckyGiftPayout),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")),
|
||||||
|
label: "利润",
|
||||||
|
value: formatWholeMoney(luckyGiftProfit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"),
|
||||||
|
label: "返奖率",
|
||||||
|
value: formatPercent(source.lucky_gift_payout_rate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"),
|
||||||
|
label: "利润率",
|
||||||
|
value: formatPercent(source.lucky_gift_profit_rate),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
luckyGiftPools,
|
luckyGiftPools,
|
||||||
payoutDistribution,
|
payoutDistribution,
|
||||||
@ -112,22 +232,54 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
||||||
revenueSeries,
|
revenueSeries,
|
||||||
robotGiftKpis: [
|
robotGiftKpis: [
|
||||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
|
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", {
|
||||||
coinMetric("真人房机器人幸运礼物", realRoomRobotLuckyGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_lucky_gift_coin", "真人房机器人幸运礼物", "coin") }),
|
trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin"),
|
||||||
coinMetric("真人房机器人超级幸运礼物", realRoomRobotSuperGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_super_gift_coin", "真人房机器人超级幸运礼物", "coin") }),
|
}),
|
||||||
coinMetric("真人房机器人房均礼物", realRoomRobotAvgRoomGiftCoin, "", `${formatNumber(realRoomRobotGiftRoomCount)} 个房间`, { trend: metricTrend(dailySeries, "real_room_robot_avg_room_gift_coin", "真人房机器人房均礼物", "coin") })
|
coinMetric("真人房机器人幸运礼物", realRoomRobotLuckyGiftCoin, "", "当前筛选", {
|
||||||
|
trend: metricTrend(dailySeries, "real_room_robot_lucky_gift_coin", "真人房机器人幸运礼物", "coin"),
|
||||||
|
}),
|
||||||
|
coinMetric("真人房机器人超级幸运礼物", realRoomRobotSuperGiftCoin, "", "当前筛选", {
|
||||||
|
trend: metricTrend(dailySeries, "real_room_robot_super_gift_coin", "真人房机器人超级幸运礼物", "coin"),
|
||||||
|
}),
|
||||||
|
coinMetric("真人房机器人房均礼物", realRoomRobotAvgRoomGiftCoin, "", `${formatNumber(realRoomRobotGiftRoomCount)} 个房间`, {
|
||||||
|
trend: metricTrend(dailySeries, "real_room_robot_avg_room_gift_coin", "真人房机器人房均礼物", "coin"),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
roomMetrics: [
|
roomMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
|
{
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")),
|
||||||
|
label: "礼物消费(充值)",
|
||||||
|
value: formatWholeMoney(source.gift_coin_spent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(
|
||||||
|
comparisonCaption,
|
||||||
|
readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"),
|
||||||
|
),
|
||||||
|
label: "幸运礼物流水",
|
||||||
|
value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
sideMetrics: [
|
sideMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
{
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, activeUsers)) },
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")),
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"),
|
||||||
|
label: "ARPU",
|
||||||
|
value: formatMoney(arpu),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"),
|
||||||
|
label: "付费转化率",
|
||||||
|
value: formatPercent(ratio(paidUsers, activeUsers)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")),
|
||||||
|
label: "ARPPU",
|
||||||
|
value: formatMoney(arppu),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
topCountries: countryBreakdown.slice(0, 4),
|
topCountries: countryBreakdown.slice(0, 4),
|
||||||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone)
|
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,6 +309,10 @@ function readOptionalNumber(source, ...keys) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAnyKey(source, ...keys) {
|
||||||
|
return keys.some((key) => source?.[key] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function readDelta(source, ...keys) {
|
function readDelta(source, ...keys) {
|
||||||
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@ -204,7 +360,7 @@ function normalizeRevenueSeries(source) {
|
|||||||
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
||||||
lineTotal: Math.max(numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? total), channelTotal),
|
lineTotal: Math.max(numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? total), channelTotal),
|
||||||
mifapay,
|
mifapay,
|
||||||
total
|
total,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -219,26 +375,32 @@ function normalizeRevenueSeries(source) {
|
|||||||
label: "当前",
|
label: "当前",
|
||||||
lineTotal: total,
|
lineTotal: total,
|
||||||
mifapay,
|
mifapay,
|
||||||
total
|
total,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCountries(source, countryId) {
|
function normalizeCountries(source, countryId) {
|
||||||
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
||||||
const rows = raw || (countryId ? [{
|
const rows =
|
||||||
|
raw ||
|
||||||
|
(countryId
|
||||||
|
? [
|
||||||
|
{
|
||||||
active_users: source.active_users,
|
active_users: source.active_users,
|
||||||
arppu_usd_minor: source.arppu_usd_minor,
|
arppu_usd_minor: source.arppu_usd_minor,
|
||||||
country: `国家 ${countryId}`,
|
country: `国家 ${countryId}`,
|
||||||
paid_users: source.paid_users,
|
paid_users: source.paid_users,
|
||||||
recharge_usd_minor: source.recharge_usd_minor,
|
recharge_usd_minor: source.recharge_usd_minor,
|
||||||
x: 56,
|
x: 56,
|
||||||
y: 52
|
y: 52,
|
||||||
}] : []);
|
},
|
||||||
|
]
|
||||||
|
: []);
|
||||||
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||||||
return rows
|
return rows
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
...normalizeCountryRow(item, index, totalRecharge)
|
...normalizeCountryRow(item, index, totalRecharge),
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
||||||
}
|
}
|
||||||
@ -252,7 +414,7 @@ function normalizeDailyCountryBreakdown(source) {
|
|||||||
return rows.map((item, index) => ({
|
return rows.map((item, index) => ({
|
||||||
...normalizeCountryRow(item, index, totalRecharge),
|
...normalizeCountryRow(item, index, totalRecharge),
|
||||||
date_label: item.label || item.stat_day || item.statDay || item.day || "",
|
date_label: item.label || item.stat_day || item.statDay || item.day || "",
|
||||||
stat_day: item.stat_day || item.statDay || item.day || ""
|
stat_day: item.stat_day || item.statDay || item.day || "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,45 +422,40 @@ function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, daily
|
|||||||
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
|
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
|
||||||
country: "总计",
|
country: "总计",
|
||||||
date_label: "全部",
|
date_label: "全部",
|
||||||
row_type: "total"
|
row_type: "total",
|
||||||
});
|
});
|
||||||
if (!isMultiDayRange(range)) {
|
if (!isMultiDayRange(range)) {
|
||||||
return [
|
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))];
|
||||||
totalRow,
|
|
||||||
...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
|
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
|
||||||
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
|
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
|
||||||
const dayKeys = Array.from(new Set([
|
const dayKeys = Array.from(new Set([...dailySeries.map((item) => item.stat_day || item.label).filter(Boolean), ...dailyCountryBreakdown.map((item) => item.stat_day).filter(Boolean)])).sort();
|
||||||
...dailySeries.map((item) => item.stat_day || item.label).filter(Boolean),
|
|
||||||
...dailyCountryBreakdown.map((item) => item.stat_day).filter(Boolean)
|
|
||||||
])).sort();
|
|
||||||
|
|
||||||
if (!dayKeys.length) {
|
if (!dayKeys.length) {
|
||||||
return [
|
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))];
|
||||||
totalRow,
|
|
||||||
...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [totalRow];
|
const rows = [totalRow];
|
||||||
for (const day of dayKeys) {
|
for (const day of dayKeys) {
|
||||||
const dayCountries = dailyRowsByDay.get(day) || [];
|
const dayCountries = dailyRowsByDay.get(day) || [];
|
||||||
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
|
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
|
||||||
const childRows = dayCountries.map((row) => createReportRow(row, {
|
const childRows = dayCountries.map((row) =>
|
||||||
|
createReportRow(row, {
|
||||||
date_label: day,
|
date_label: day,
|
||||||
row_type: "country",
|
row_type: "country",
|
||||||
stat_day: day
|
stat_day: day,
|
||||||
}));
|
}),
|
||||||
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
);
|
||||||
|
rows.push(
|
||||||
|
createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||||
children: childRows,
|
children: childRows,
|
||||||
country: "总计",
|
country: "总计",
|
||||||
date_label: day,
|
date_label: day,
|
||||||
row_type: "date_total",
|
row_type: "date_total",
|
||||||
stat_day: day
|
stat_day: day,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@ -308,12 +465,7 @@ function createReportRow(row, extra) {
|
|||||||
...row,
|
...row,
|
||||||
...extra,
|
...extra,
|
||||||
flag: extra.row_type === "country" ? row.flag : "",
|
flag: extra.row_type === "country" ? row.flag : "",
|
||||||
row_id: [
|
row_id: [extra.row_type, extra.stat_day || extra.date_label || "", row.country_id || row.country || "", row.region_id || ""].join(":"),
|
||||||
extra.row_type,
|
|
||||||
extra.stat_day || extra.date_label || "",
|
|
||||||
row.country_id || row.country || "",
|
|
||||||
row.region_id || ""
|
|
||||||
].join(":")
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,6 +491,7 @@ function summarizeReportRows(rows) {
|
|||||||
"coin_seller_transfer_coin",
|
"coin_seller_transfer_coin",
|
||||||
"coin_total",
|
"coin_total",
|
||||||
"consumed_coin",
|
"consumed_coin",
|
||||||
|
"output_coin",
|
||||||
"game_payout",
|
"game_payout",
|
||||||
"game_refund",
|
"game_refund",
|
||||||
"game_turnover",
|
"game_turnover",
|
||||||
@ -359,17 +512,30 @@ function summarizeReportRows(rows) {
|
|||||||
"salary_usd_minor",
|
"salary_usd_minor",
|
||||||
"salary_transfer_coin",
|
"salary_transfer_coin",
|
||||||
"super_lucky_gift_payout",
|
"super_lucky_gift_payout",
|
||||||
"super_lucky_gift_turnover"
|
"super_lucky_gift_turnover",
|
||||||
];
|
];
|
||||||
const summary = fields.reduce((summary, key) => {
|
const summary = fields.reduce((summary, key) => {
|
||||||
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
|
summary[key] = sumOptionalField(rows, key);
|
||||||
return summary;
|
return summary;
|
||||||
}, {});
|
}, {});
|
||||||
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算,不能把国家平均值相加。
|
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算;如果下游 App 没有麦上时长字段,保留 null 让报表展示缺失而不是 0。
|
||||||
summary.avg_mic_online_ms = Math.round(ratio(summary.mic_online_ms, summary.mic_online_users));
|
summary.avg_mic_online_ms = summary.mic_online_ms === null || summary.mic_online_users === null ? null : Math.round(ratio(summary.mic_online_ms, summary.mic_online_users));
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sumOptionalField(rows, key) {
|
||||||
|
let hasValue = false;
|
||||||
|
const total = rows.reduce((sum, row) => {
|
||||||
|
const value = row?.[key];
|
||||||
|
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
hasValue = true;
|
||||||
|
return sum + numberValue(value);
|
||||||
|
}, 0);
|
||||||
|
return hasValue ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isMultiDayRange(range) {
|
function isMultiDayRange(range) {
|
||||||
return Boolean(range?.start && range?.end && range.start !== range.end);
|
return Boolean(range?.start && range?.end && range.start !== range.end);
|
||||||
}
|
}
|
||||||
@ -380,79 +546,94 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
||||||
const meta = resolveCountryMeta({ code, name: country });
|
const meta = resolveCountryMeta({ code, name: country });
|
||||||
const recharge = effectiveRechargeUSDMinor(item);
|
const recharge = effectiveRechargeUSDMinor(item);
|
||||||
const activeUsers = numberValue(item.active_users);
|
const activeUsers = readOptionalNumber(item, "active_users", "activeUsers");
|
||||||
const paidUsers = numberValue(item.paid_users);
|
const paidUsers = readOptionalNumber(item, "paid_users", "paidUsers");
|
||||||
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
|
const rechargeUsers = readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
||||||
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
const gameTurnover = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||||
const gamePayout = readNumber(item, "game_payout", "gamePayout");
|
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||||
const gameRefund = readNumber(item, "game_refund", "gameRefund");
|
const gameRefund = readOptionalNumber(item, "game_refund", "gameRefund") ?? 0;
|
||||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover - gamePayout - gameRefund);
|
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover === null || gamePayout === null ? null : gameTurnover - gamePayout - gameRefund);
|
||||||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
const luckyGiftTurnover = readOptionalNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
const luckyGiftPayout = readOptionalNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||||
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover - luckyGiftPayout);
|
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover === null || luckyGiftPayout === null ? null : luckyGiftTurnover - luckyGiftPayout);
|
||||||
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
|
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
|
||||||
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
|
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
|
||||||
const superLuckyGiftProfit = readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
|
const superLuckyGiftProfit =
|
||||||
|
readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
|
||||||
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
|
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
|
||||||
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
||||||
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
|
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||||
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
const consumedCoin = readOptionalNumber(item, "consumed_coin", "consumedCoin");
|
||||||
|
const outputCoin = readOptionalNumber(item, "output_coin", "outputCoin");
|
||||||
|
const consumeOutputRatio = readOptionalNumber(item, "consume_output_ratio", "consumeOutputRatio") ?? (consumedCoin === null || outputCoin === null ? null : ratio(outputCoin, consumedCoin));
|
||||||
|
const consumeOutputDelta = readOptionalNumber(item, "consume_output_delta", "consumeOutputDelta") ?? (consumedCoin === null || outputCoin === null ? null : consumedCoin - outputCoin);
|
||||||
return {
|
return {
|
||||||
active_users: activeUsers,
|
active_users: activeUsers,
|
||||||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
avg_recharge_usd_minor: readOptionalNumber(item, "avg_recharge_usd_minor", "avgRechargeUsdMinor"),
|
||||||
avg_mic_online_ms: avgMicOnlineMS,
|
avg_mic_online_ms: avgMicOnlineMS,
|
||||||
coin_seller_recharge_usd_minor: readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
|
coin_seller_recharge_usd_minor: readOptionalNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
|
||||||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||||||
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
|
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||||||
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||||||
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
|
consumed_coin: consumedCoin,
|
||||||
|
consume_output_delta: consumeOutputDelta,
|
||||||
|
consume_output_ratio: consumeOutputRatio,
|
||||||
country: meta?.label || country,
|
country: meta?.label || country,
|
||||||
country_id: countryId,
|
country_id: countryId,
|
||||||
country_code: code || meta?.code || "",
|
country_code: code || meta?.code || "",
|
||||||
flag: countryFlag(code || meta?.code),
|
flag: countryFlag(code || meta?.code),
|
||||||
game_payout: gamePayout,
|
game_payout: gamePayout,
|
||||||
game_profit: gameProfit,
|
game_profit: gameProfit,
|
||||||
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? ratio(gameProfit, gameTurnover),
|
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? (gameProfit === null || gameTurnover === null ? null : ratio(gameProfit, gameTurnover)),
|
||||||
game_refund: gameRefund,
|
game_refund: gameRefund,
|
||||||
game_turnover: gameTurnover,
|
game_turnover: gameTurnover,
|
||||||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||||
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
google_recharge_usd_minor: readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||||||
lucky_gift_payout: luckyGiftPayout,
|
lucky_gift_payout: luckyGiftPayout,
|
||||||
lucky_gift_profit: luckyGiftProfit,
|
lucky_gift_profit: luckyGiftProfit,
|
||||||
lucky_gift_profit_rate: readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? ratio(luckyGiftProfit, luckyGiftTurnover),
|
lucky_gift_profit_rate:
|
||||||
|
readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? (luckyGiftProfit === null || luckyGiftTurnover === null ? null : ratio(luckyGiftProfit, luckyGiftTurnover)),
|
||||||
lucky_gift_turnover: luckyGiftTurnover,
|
lucky_gift_turnover: luckyGiftTurnover,
|
||||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||||
mic_online_ms: micOnlineMS,
|
mic_online_ms: micOnlineMS,
|
||||||
mic_online_users: micOnlineUsers,
|
mic_online_users: micOnlineUsers,
|
||||||
mifapay_recharge_usd_minor: readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
|
mifapay_recharge_usd_minor: readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
|
||||||
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||||
paid_users: paidUsers,
|
paid_users: paidUsers,
|
||||||
payer_rate: ratio(paidUsers, activeUsers),
|
payer_rate:
|
||||||
|
readOptionalNumber(item, "payer_rate", "payerRate", "paid_conversion_rate", "paidConversionRate") ?? (paidUsers === null || activeUsers === null ? null : ratio(paidUsers, activeUsers)),
|
||||||
|
output_coin: outputCoin,
|
||||||
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||||||
recharge_usd_minor: recharge,
|
recharge_usd_minor: recharge,
|
||||||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
recharge_conversion_rate: hasAnyKey(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
? readOptionalNumber(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||||
|
: rechargeUsers === null || activeUsers === null
|
||||||
|
? null
|
||||||
: ratio(rechargeUsers, activeUsers),
|
: ratio(rechargeUsers, activeUsers),
|
||||||
recharge_users: rechargeUsers,
|
recharge_users: rechargeUsers,
|
||||||
|
region_id: numberValue(item.region_id ?? item.regionId),
|
||||||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||||
share: recharge / totalRecharge,
|
share: recharge / totalRecharge,
|
||||||
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
|
registrations: readOptionalNumber(item, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers"),
|
||||||
super_lucky_gift_payout: superLuckyGiftPayout,
|
super_lucky_gift_payout: superLuckyGiftPayout,
|
||||||
super_lucky_gift_profit: superLuckyGiftProfit,
|
super_lucky_gift_profit: superLuckyGiftProfit,
|
||||||
super_lucky_gift_profit_rate: readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ?? ratio(superLuckyGiftProfit, superLuckyGiftTurnover),
|
super_lucky_gift_profit_rate:
|
||||||
|
readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ??
|
||||||
|
(superLuckyGiftProfit === null || superLuckyGiftTurnover === null ? null : ratio(superLuckyGiftProfit, superLuckyGiftTurnover)),
|
||||||
super_lucky_gift_turnover: superLuckyGiftTurnover,
|
super_lucky_gift_turnover: superLuckyGiftTurnover,
|
||||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveRechargeUSDMinor(source) {
|
function effectiveRechargeUSDMinor(source) {
|
||||||
const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
|
||||||
const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") +
|
const channelTotal =
|
||||||
|
readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") +
|
||||||
readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") +
|
readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") +
|
||||||
readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
||||||
return Math.max(direct, channelTotal);
|
return Math.max(direct, channelTotal);
|
||||||
@ -472,56 +653,70 @@ function normalizeDailySeries(source) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return source.daily_series.map((item) => {
|
return source.daily_series.map((item) => {
|
||||||
const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
const googleMetric = readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
||||||
const mifapay = readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
|
const mifapayMetric = readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
|
||||||
const coinSeller = readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
|
const coinSellerMetric = readOptionalNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
|
||||||
|
const google = googleMetric ?? 0;
|
||||||
|
const mifapay = mifapayMetric ?? 0;
|
||||||
|
const coinSeller = coinSellerMetric ?? 0;
|
||||||
const channelTotal = google + mifapay + coinSeller;
|
const channelTotal = google + mifapay + coinSeller;
|
||||||
const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal);
|
const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal);
|
||||||
const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller));
|
const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller));
|
||||||
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
const gameTurnover = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||||
const gameProfit = readNumber(item, "game_profit", "gameProfit");
|
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit");
|
||||||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
const luckyGiftTurnover = readOptionalNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||||
|
const luckyGiftPayout = readOptionalNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||||
|
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover === null || luckyGiftPayout === null ? null : luckyGiftTurnover - luckyGiftPayout);
|
||||||
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
||||||
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
|
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||||
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
const consumedCoin = readOptionalNumber(item, "consumed_coin", "consumedCoin");
|
||||||
|
const outputCoin = readOptionalNumber(item, "output_coin", "outputCoin");
|
||||||
|
const consumeOutputRatio = readOptionalNumber(item, "consume_output_ratio", "consumeOutputRatio") ?? (consumedCoin === null || outputCoin === null ? null : ratio(outputCoin, consumedCoin));
|
||||||
|
const consumeOutputDelta = readOptionalNumber(item, "consume_output_delta", "consumeOutputDelta") ?? (consumedCoin === null || outputCoin === null ? null : consumedCoin - outputCoin);
|
||||||
return {
|
return {
|
||||||
active_users: readNumber(item, "active_users", "activeUsers"),
|
active_users: readOptionalNumber(item, "active_users", "activeUsers"),
|
||||||
arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||||
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||||
avg_mic_online_ms: avgMicOnlineMS,
|
avg_mic_online_ms: avgMicOnlineMS,
|
||||||
coin_seller: coinSeller,
|
coin_seller: coinSeller,
|
||||||
coin_seller_recharge_usd_minor: coinSeller,
|
coin_seller_recharge_usd_minor: coinSellerMetric,
|
||||||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||||||
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||||||
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||||||
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
|
consumed_coin: consumedCoin,
|
||||||
|
consume_output_delta: consumeOutputDelta,
|
||||||
|
consume_output_ratio: consumeOutputRatio,
|
||||||
|
game_payout: gamePayout,
|
||||||
game_profit: gameProfit,
|
game_profit: gameProfit,
|
||||||
game_turnover: gameTurnover,
|
game_turnover: gameTurnover,
|
||||||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||||
google,
|
google,
|
||||||
|
google_recharge_usd_minor: googleMetric,
|
||||||
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
||||||
lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
|
lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
|
||||||
lucky_gift_payout: luckyGiftPayout,
|
lucky_gift_payout: luckyGiftPayout,
|
||||||
lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
|
lucky_gift_profit: luckyGiftProfit,
|
||||||
lucky_gift_turnover: luckyGiftTurnover,
|
lucky_gift_turnover: luckyGiftTurnover,
|
||||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||||
mic_online_ms: micOnlineMS,
|
mic_online_ms: micOnlineMS,
|
||||||
mic_online_users: micOnlineUsers,
|
mic_online_users: micOnlineUsers,
|
||||||
mifapay,
|
mifapay,
|
||||||
new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"),
|
mifapay_recharge_usd_minor: mifapayMetric,
|
||||||
new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
new_paid_users: readOptionalNumber(item, "new_paid_users", "newPaidUsers"),
|
||||||
new_users: readNumber(item, "new_users", "newUsers"),
|
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||||
paid_users: readNumber(item, "paid_users", "paidUsers"),
|
new_users: readOptionalNumber(item, "new_users", "newUsers"),
|
||||||
|
output_coin: outputCoin,
|
||||||
|
paid_users: readOptionalNumber(item, "paid_users", "paidUsers"),
|
||||||
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||||||
recharge_usd_minor: recharge,
|
recharge_usd_minor: recharge,
|
||||||
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
recharge_users: readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
||||||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||||
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
||||||
total: recharge,
|
total: recharge,
|
||||||
user_recharge_usd_minor: userRecharge
|
user_recharge_usd_minor: userRecharge,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -529,7 +724,7 @@ function normalizeDailySeries(source) {
|
|||||||
function metricTrend(series, key, name, format = "number") {
|
function metricTrend(series, key, name, format = "number") {
|
||||||
const data = series.map((item) => ({
|
const data = series.map((item) => ({
|
||||||
label: item.label || item.stat_day || "-",
|
label: item.label || item.stat_day || "-",
|
||||||
value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key])
|
value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key]),
|
||||||
}));
|
}));
|
||||||
return data.length ? [{ data, name }] : [];
|
return data.length ? [{ data, name }] : [];
|
||||||
}
|
}
|
||||||
@ -539,7 +734,7 @@ function normalizeGiftRanking(source) {
|
|||||||
return source.gift_ranking.map((item) => ({
|
return source.gift_ranking.map((item) => ({
|
||||||
name: item.name || item.gift_name || item.gift_id || "-",
|
name: item.name || item.gift_name || item.gift_id || "-",
|
||||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||||
value: numberValue(item.value ?? item.amount ?? item.coin)
|
value: numberValue(item.value ?? item.amount ?? item.coin),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@ -554,7 +749,7 @@ function normalizeDistribution(source) {
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
|
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
|
||||||
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
|
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,18 +767,21 @@ function normalizeLuckyGiftPools(source) {
|
|||||||
pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-",
|
pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-",
|
||||||
profit,
|
profit,
|
||||||
profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)),
|
profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)),
|
||||||
turnover
|
turnover,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id));
|
.sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeLuckyGiftPools(items) {
|
function summarizeLuckyGiftPools(items) {
|
||||||
return items.reduce((summary, item) => ({
|
return items.reduce(
|
||||||
|
(summary, item) => ({
|
||||||
payout: summary.payout + item.payout,
|
payout: summary.payout + item.payout,
|
||||||
profit: summary.profit + item.profit,
|
profit: summary.profit + item.profit,
|
||||||
turnover: summary.turnover + item.turnover
|
turnover: summary.turnover + item.turnover,
|
||||||
}), { payout: 0, profit: 0, turnover: 0 });
|
}),
|
||||||
|
{ payout: 0, profit: 0, turnover: 0 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstArray(...values) {
|
function firstArray(...values) {
|
||||||
@ -598,7 +796,7 @@ function normalizeGameRanking(source) {
|
|||||||
game_name: item.game_name || item.gameName || item.name || "",
|
game_name: item.game_name || item.gameName || item.name || "",
|
||||||
platform_code: item.platform_code || item.platformCode || "",
|
platform_code: item.platform_code || item.platformCode || "",
|
||||||
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
|
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
|
||||||
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
|
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -2,19 +2,22 @@ import { expect, test } from "vitest";
|
|||||||
import { createDashboardModel } from "./createDashboardModel.js";
|
import { createDashboardModel } from "./createDashboardModel.js";
|
||||||
|
|
||||||
test("uses lucky gift pool breakdown to aggregate business metrics", () => {
|
test("uses lucky gift pool breakdown to aggregate business metrics", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
lucky_gift_pools: [
|
lucky_gift_pools: [
|
||||||
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
|
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
|
||||||
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 }
|
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 },
|
||||||
],
|
],
|
||||||
lucky_gift_payout: 1,
|
lucky_gift_payout: 1,
|
||||||
lucky_gift_profit: 1,
|
lucky_gift_profit: 1,
|
||||||
lucky_gift_turnover: 1
|
lucky_gift_turnover: 1,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(model.luckyGiftPools).toEqual([
|
expect(model.luckyGiftPools).toEqual([
|
||||||
expect.objectContaining({ payout: 300, pool_id: "lucky_100", profit: 700, turnover: 1_000 }),
|
expect.objectContaining({ payout: 300, pool_id: "lucky_100", profit: 700, turnover: 1_000 }),
|
||||||
expect.objectContaining({ payout: 50, pool_id: "super_lucky", profit: 450, turnover: 500 })
|
expect.objectContaining({ payout: 50, pool_id: "super_lucky", profit: 450, turnover: 500 }),
|
||||||
]);
|
]);
|
||||||
expect(metricValue(model, "幸运礼物流水")).toBe("1,500");
|
expect(metricValue(model, "幸运礼物流水")).toBe("1,500");
|
||||||
expect(metricValue(model, "幸运礼物返奖")).toBe("350");
|
expect(metricValue(model, "幸运礼物返奖")).toBe("350");
|
||||||
@ -23,7 +26,8 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
|
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
coin_seller_recharge_usd_minor: 200,
|
coin_seller_recharge_usd_minor: 200,
|
||||||
coin_seller_stock_coin: 400_000,
|
coin_seller_stock_coin: 400_000,
|
||||||
coin_seller_transfer_coin: 160_000,
|
coin_seller_transfer_coin: 160_000,
|
||||||
@ -36,35 +40,47 @@ test("includes coin seller stock recharge in USD total and keeps seller transfer
|
|||||||
country: "阿富汗",
|
country: "阿富汗",
|
||||||
google_recharge_usd_minor: 150,
|
google_recharge_usd_minor: 150,
|
||||||
recharge_usd_minor: 150,
|
recharge_usd_minor: 150,
|
||||||
salary_transfer_coin: 88_000
|
salary_transfer_coin: 88_000,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
daily_series: [
|
daily_series: [
|
||||||
{
|
{
|
||||||
coin_seller: 200,
|
coin_seller: 200,
|
||||||
google: 150,
|
google: 150,
|
||||||
label: "当前",
|
label: "当前",
|
||||||
recharge_usd_minor: 150
|
recharge_usd_minor: 150,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
google_recharge_usd_minor: 150,
|
google_recharge_usd_minor: 150,
|
||||||
recharge_usd_minor: 150
|
recharge_usd_minor: 150,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(metricValue(model, "总充值")).toBe("$4");
|
expect(metricValue(model, "总充值")).toBe("$4");
|
||||||
expect(metricValue(model, "币商出货金币")).toBe("160,000");
|
expect(metricValue(model, "币商出货金币")).toBe("160,000");
|
||||||
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
|
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
|
||||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_stock_coin: 400_000, coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350, salary_transfer_coin: 88_000 }));
|
expect(model.countryBreakdown[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
recharge_usd_minor: 350,
|
||||||
|
salary_transfer_coin: 88_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exposes new user KPI and removes visitor stage from funnel", () => {
|
test("exposes new user KPI and removes visitor stage from funnel", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
active_users: 80,
|
active_users: 80,
|
||||||
new_users: 12,
|
new_users: 12,
|
||||||
new_users_delta_rate: 0.24,
|
new_users_delta_rate: 0.24,
|
||||||
paid_users: 8,
|
paid_users: 8,
|
||||||
recharge_users: 7
|
recharge_users: 7,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(metricValue(model, "新增用户数")).toBe("12");
|
expect(metricValue(model, "新增用户数")).toBe("12");
|
||||||
expect(model.kpis.map((item) => item.label)).toContain("新增用户数");
|
expect(model.kpis.map((item) => item.label)).toContain("新增用户数");
|
||||||
@ -72,12 +88,15 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("adds real-room robot gift metrics to a separate card row", () => {
|
test("adds real-room robot gift metrics to a separate card row", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
real_room_robot_gift_room_count: 2,
|
real_room_robot_gift_room_count: 2,
|
||||||
real_room_robot_lucky_gift_coin: 200,
|
real_room_robot_lucky_gift_coin: 200,
|
||||||
real_room_robot_normal_gift_coin: 100,
|
real_room_robot_normal_gift_coin: 100,
|
||||||
real_room_robot_super_gift_coin: 300
|
real_room_robot_super_gift_coin: 300,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(metricValue(model, "真人房机器人普通礼物")).toBe("100");
|
expect(metricValue(model, "真人房机器人普通礼物")).toBe("100");
|
||||||
expect(metricValue(model, "真人房机器人幸运礼物")).toBe("200");
|
expect(metricValue(model, "真人房机器人幸运礼物")).toBe("200");
|
||||||
@ -88,7 +107,8 @@ test("adds real-room robot gift metrics to a separate card row", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("uses active users as payer and recharge conversion denominator", () => {
|
test("uses active users as payer and recharge conversion denominator", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
active_users: 80,
|
active_users: 80,
|
||||||
country_breakdown: [
|
country_breakdown: [
|
||||||
{
|
{
|
||||||
@ -96,43 +116,51 @@ test("uses active users as payer and recharge conversion denominator", () => {
|
|||||||
country: "巴西",
|
country: "巴西",
|
||||||
paid_users: 8,
|
paid_users: 8,
|
||||||
recharge_users: 6,
|
recharge_users: 6,
|
||||||
recharge_usd_minor: 500
|
recharge_usd_minor: 500,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
new_users: 4,
|
new_users: 4,
|
||||||
paid_users: 8,
|
paid_users: 8,
|
||||||
recharge_users: 6
|
recharge_users: 6,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
|
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
|
||||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
expect(model.countryBreakdown[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
payer_rate: 0.4,
|
payer_rate: 0.4,
|
||||||
recharge_conversion_rate: 0.3
|
recharge_conversion_rate: 0.3,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses registration count instead of legacy visitors in country breakdown", () => {
|
test("uses registration count instead of legacy visitors in country breakdown", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
country_breakdown: [
|
country_breakdown: [
|
||||||
{
|
{
|
||||||
country: "菲律宾",
|
country: "菲律宾",
|
||||||
new_users: 12,
|
new_users: 12,
|
||||||
visitors: 99
|
visitors: 99,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
expect(model.countryBreakdown[0]).toEqual(
|
||||||
registrations: 12
|
expect.objectContaining({
|
||||||
}));
|
registrations: 12,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("builds report rows with range total, daily totals, and daily country rows", () => {
|
test("builds report rows with range total, daily totals, and daily country rows", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel(
|
||||||
country_breakdown: [
|
{
|
||||||
{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }
|
country_breakdown: [{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }],
|
||||||
],
|
|
||||||
daily_country_breakdown: [
|
daily_country_breakdown: [
|
||||||
{
|
{
|
||||||
active_users: 10,
|
active_users: 10,
|
||||||
@ -147,21 +175,22 @@ test("builds report rows with range total, daily totals, and daily country rows"
|
|||||||
manual_grant_coin: 20,
|
manual_grant_coin: 20,
|
||||||
mic_online_ms: 6_000,
|
mic_online_ms: 6_000,
|
||||||
mic_online_users: 3,
|
mic_online_users: 3,
|
||||||
|
output_coin: 120,
|
||||||
platform_grant_coin: 150,
|
platform_grant_coin: 150,
|
||||||
recharge_usd_minor: 100,
|
recharge_usd_minor: 100,
|
||||||
salary_usd_minor: 900,
|
salary_usd_minor: 900,
|
||||||
salary_transfer_coin: 700,
|
salary_transfer_coin: 700,
|
||||||
stat_day: "2026-06-05",
|
stat_day: "2026-06-05",
|
||||||
super_lucky_gift_turnover: 80,
|
super_lucky_gift_turnover: 80,
|
||||||
super_lucky_gift_payout: 20
|
super_lucky_gift_payout: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
active_users: 20,
|
active_users: 20,
|
||||||
country: "巴西",
|
country: "巴西",
|
||||||
country_id: 86,
|
country_id: 86,
|
||||||
recharge_usd_minor: 200,
|
recharge_usd_minor: 200,
|
||||||
stat_day: "2026-06-06"
|
stat_day: "2026-06-06",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
daily_series: [
|
daily_series: [
|
||||||
{
|
{
|
||||||
@ -172,46 +201,111 @@ test("builds report rows with range total, daily totals, and daily country rows"
|
|||||||
manual_grant_coin: 20,
|
manual_grant_coin: 20,
|
||||||
mic_online_ms: 6_000,
|
mic_online_ms: 6_000,
|
||||||
mic_online_users: 3,
|
mic_online_users: 3,
|
||||||
|
output_coin: 120,
|
||||||
platform_grant_coin: 150,
|
platform_grant_coin: 150,
|
||||||
recharge_usd_minor: 100,
|
recharge_usd_minor: 100,
|
||||||
salary_usd_minor: 900,
|
salary_usd_minor: 900,
|
||||||
stat_day: "2026-06-05"
|
stat_day: "2026-06-05",
|
||||||
},
|
},
|
||||||
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" }
|
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" },
|
||||||
],
|
],
|
||||||
recharge_usd_minor: 300
|
recharge_usd_minor: 300,
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
},
|
||||||
|
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "date_total"]);
|
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "date_total"]);
|
||||||
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
||||||
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({
|
expect(model.reportCountryRows[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
avg_mic_online_ms: 2_000,
|
avg_mic_online_ms: 2_000,
|
||||||
coin_total: 1_200,
|
coin_total: 1_200,
|
||||||
consumed_coin: 300,
|
consumed_coin: 300,
|
||||||
|
consume_output_delta: 180,
|
||||||
|
consume_output_ratio: 0.4,
|
||||||
country: "总计",
|
country: "总计",
|
||||||
date_label: "2026-06-05",
|
date_label: "2026-06-05",
|
||||||
manual_grant_coin: 20,
|
manual_grant_coin: 20,
|
||||||
mic_online_ms: 6_000,
|
mic_online_ms: 6_000,
|
||||||
|
output_coin: 120,
|
||||||
platform_grant_coin: 150,
|
platform_grant_coin: 150,
|
||||||
salary_usd_minor: 900
|
salary_usd_minor: 900,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
expect(model.reportCountryRows[1].children).toHaveLength(1);
|
expect(model.reportCountryRows[1].children).toHaveLength(1);
|
||||||
expect(model.reportCountryRows[1].children[0]).toEqual(expect.objectContaining({
|
expect(model.reportCountryRows[1].children[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
avg_mic_online_ms: 2_000,
|
avg_mic_online_ms: 2_000,
|
||||||
coin_seller_stock_coin: 800,
|
coin_seller_stock_coin: 800,
|
||||||
coin_seller_transfer_coin: 500,
|
coin_seller_transfer_coin: 500,
|
||||||
coin_total: 1_200,
|
coin_total: 1_200,
|
||||||
consumed_coin: 300,
|
consumed_coin: 300,
|
||||||
|
consume_output_delta: 180,
|
||||||
|
consume_output_ratio: 0.4,
|
||||||
country: "巴西",
|
country: "巴西",
|
||||||
game_turnover: 300,
|
game_turnover: 300,
|
||||||
lucky_gift_turnover: 200,
|
lucky_gift_turnover: 200,
|
||||||
manual_grant_coin: 20,
|
manual_grant_coin: 20,
|
||||||
mic_online_ms: 6_000,
|
mic_online_ms: 6_000,
|
||||||
|
output_coin: 120,
|
||||||
platform_grant_coin: 150,
|
platform_grant_coin: 150,
|
||||||
salary_usd_minor: 900,
|
salary_usd_minor: 900,
|
||||||
salary_transfer_coin: 700,
|
salary_transfer_coin: 700,
|
||||||
super_lucky_gift_profit: 60
|
super_lucky_gift_profit: 60,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps unavailable external report metrics as missing values", () => {
|
||||||
|
const model = createDashboardModel(
|
||||||
|
{
|
||||||
|
country_breakdown: [
|
||||||
|
{
|
||||||
|
active_users: 12,
|
||||||
|
arppu_usd_minor: null,
|
||||||
|
coin_seller_recharge_usd_minor: null,
|
||||||
|
coin_seller_transfer_coin: null,
|
||||||
|
country: "Saudi Arabia",
|
||||||
|
game_profit_rate: null,
|
||||||
|
game_turnover: null,
|
||||||
|
gift_coin_spent: null,
|
||||||
|
lucky_gift_profit_rate: null,
|
||||||
|
lucky_gift_turnover: null,
|
||||||
|
paid_users: null,
|
||||||
|
recharge_conversion_rate: null,
|
||||||
|
recharge_users: null,
|
||||||
|
recharge_usd_minor: 10_025,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recharge_usd_minor: 10_025,
|
||||||
|
},
|
||||||
|
{ appCode: "aslan", countryId: 0, range: { end: "2026-06-30", start: "2026-06-30" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.countryBreakdown[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
arppu_usd_minor: null,
|
||||||
|
coin_seller_recharge_usd_minor: null,
|
||||||
|
coin_seller_transfer_coin: null,
|
||||||
|
game_profit_rate: null,
|
||||||
|
game_turnover: null,
|
||||||
|
gift_coin_spent: null,
|
||||||
|
lucky_gift_profit_rate: null,
|
||||||
|
lucky_gift_turnover: null,
|
||||||
|
paid_users: null,
|
||||||
|
payer_rate: null,
|
||||||
|
recharge_conversion_rate: null,
|
||||||
|
recharge_users: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(model.reportCountryRows[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
arppu_usd_minor: null,
|
||||||
|
game_turnover: null,
|
||||||
|
paid_users: null,
|
||||||
|
recharge_users: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function metricValue(model, label) {
|
function metricValue(model, label) {
|
||||||
|
|||||||
505
databi/src/data/socialBiDashboard.js
Normal file
505
databi/src/data/socialBiDashboard.js
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
export const ALL_VALUE = "all";
|
||||||
|
|
||||||
|
export const socialBiAppOptions = [
|
||||||
|
{ label: "全部 App", value: ALL_VALUE },
|
||||||
|
{ label: "Lalu", value: "lalu" },
|
||||||
|
{ label: "Aslan", value: "aslan" },
|
||||||
|
{ label: "Yumi", value: "yumi" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiOperatorOptions = [
|
||||||
|
{ label: "全部人员", searchText: "all", value: ALL_VALUE },
|
||||||
|
{ label: "Ava Chen", searchText: "ava chen 陈", value: "ava" },
|
||||||
|
{ label: "Nora Li", searchText: "nora li 李", value: "nora" },
|
||||||
|
{ label: "Omar Saleh", searchText: "omar saleh", value: "omar" },
|
||||||
|
{ label: "Lina Gomez", searchText: "lina gomez", value: "lina" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiDepartmentOptions = [
|
||||||
|
{ label: "全部部门", value: ALL_VALUE },
|
||||||
|
{ label: "拉美运营一部", value: "latam-ops-1" },
|
||||||
|
{ label: "中东运营部", value: "mena-ops" },
|
||||||
|
{ label: "泛娱乐增长组", value: "growth" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiRegionOptions = [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "巴西", value: "br" },
|
||||||
|
{ label: "墨西哥", value: "mx" },
|
||||||
|
{ label: "哥伦比亚", value: "co" }
|
||||||
|
],
|
||||||
|
label: "拉美大区",
|
||||||
|
value: "latam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "沙特", value: "sa" },
|
||||||
|
{ label: "阿联酋", value: "ae" },
|
||||||
|
{ label: "埃及", value: "eg" }
|
||||||
|
],
|
||||||
|
label: "中东大区",
|
||||||
|
value: "mena"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ label: "印度尼西亚", value: "id" },
|
||||||
|
{ label: "菲律宾", value: "ph" }
|
||||||
|
],
|
||||||
|
label: "东南亚大区",
|
||||||
|
value: "sea"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socialBiFilterDefinitions = {
|
||||||
|
apps: { label: "App", options: socialBiAppOptions, type: "multi" },
|
||||||
|
dateRange: { label: "日期区间", type: "dateRange" },
|
||||||
|
departments: { label: "运营部门", options: socialBiDepartmentOptions, type: "multi" },
|
||||||
|
operators: { label: "运营人员", options: socialBiOperatorOptions, placeholder: "搜索员工姓名", type: "searchMulti" },
|
||||||
|
regions: { label: "地区", options: socialBiRegionOptions, type: "cascade" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const socialBiSections = [
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 8.4, key: "recharge", label: "总充值 ($)", type: "money" },
|
||||||
|
{ delta: -2.6, key: "newUsers", label: "总新增人数", type: "number" },
|
||||||
|
{ delta: 3.8, key: "dau", label: "总日活 (DAU)", type: "number" },
|
||||||
|
{ delta: 1.7, key: "arppu", label: "综合 ARPPU ($)", type: "money" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活"),
|
||||||
|
numberColumn("paidUsers", "付费用户"),
|
||||||
|
numberColumn("rechargeUsers", "充值用户"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
moneyColumn("newUserRecharge", "新用户充值"),
|
||||||
|
moneyColumn("coinSellerRecharge", "币商充值"),
|
||||||
|
numberColumn("coinSellerStockCoin", "币商充值金币"),
|
||||||
|
numberColumn("coinSellerTransferCoin", "币商出货金币"),
|
||||||
|
moneyColumn("googleRecharge", "谷歌充值"),
|
||||||
|
moneyColumn("thirdPartyRecharge", "三方充值"),
|
||||||
|
flowRateColumn("gameFlowProfit", "游戏流水/利润率"),
|
||||||
|
flowRateColumn("luckyGiftFlowProfit", "幸运礼物流水/利润率"),
|
||||||
|
flowRateColumn("superLuckyGiftFlowProfit", "超级幸运礼物流水/利润率"),
|
||||||
|
numberColumn("giftCoinSpent", "礼物流水"),
|
||||||
|
numberColumn("coins", "金币数量"),
|
||||||
|
numberColumn("consumedCoin", "消耗金币"),
|
||||||
|
numberColumn("outputCoin", "产出金币"),
|
||||||
|
consumeOutputColumn("consumeOutputRatio", "消耗产出比"),
|
||||||
|
numberColumn("platformGrantCoin", "平台发放金币"),
|
||||||
|
numberColumn("manualGrantCoin", "人工发放金币"),
|
||||||
|
moneyColumn("salary", "存量工资 ($)"),
|
||||||
|
numberColumn("salaryTransferCoin", "工资兑换金币"),
|
||||||
|
durationColumn("avgMicOnlineMs", "平均麦上时间", "average"),
|
||||||
|
durationColumn("micOnlineMs", "麦上总时长"),
|
||||||
|
moneyColumn("arpu", "ARPU ($)", "average"),
|
||||||
|
moneyColumn("arppu", "ARPPU ($)", "average"),
|
||||||
|
percentColumn("paidConversionRate", "付费转化率"),
|
||||||
|
percentColumn("rechargeConversionRate", "充值转化率"),
|
||||||
|
moneyColumn("turnover", "App流水 ($)"),
|
||||||
|
percentColumn("retentionD1", "次日留存"),
|
||||||
|
percentColumn("retentionD7", "7日留存"),
|
||||||
|
percentColumn("retentionD30", "30日留存")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "operators"],
|
||||||
|
key: "appData",
|
||||||
|
label: "App 数据",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
arppu: 42.7,
|
||||||
|
coins: 49120000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 18520,
|
||||||
|
id: "app-lalu",
|
||||||
|
newUsers: 843,
|
||||||
|
recharge: 284300,
|
||||||
|
retentionD1: 31.6,
|
||||||
|
retentionD7: 18.4,
|
||||||
|
retentionD30: 9.8,
|
||||||
|
salary: 62310,
|
||||||
|
turnover: 493820
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
arppu: 39.4,
|
||||||
|
coins: 32760000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 12740,
|
||||||
|
id: "app-aslan",
|
||||||
|
newUsers: 612,
|
||||||
|
recharge: 196740,
|
||||||
|
retentionD1: 29.2,
|
||||||
|
retentionD7: 16.1,
|
||||||
|
retentionD30: 8.7,
|
||||||
|
salary: 42180,
|
||||||
|
turnover: 338560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
arppu: 36.2,
|
||||||
|
coins: 24890000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 9240,
|
||||||
|
id: "app-yumi",
|
||||||
|
newUsers: 438,
|
||||||
|
recharge: 142860,
|
||||||
|
retentionD1: 27.9,
|
||||||
|
retentionD7: 15.5,
|
||||||
|
retentionD30: 7.9,
|
||||||
|
salary: 31860,
|
||||||
|
turnover: 251420
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "App 数据"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 7.3, key: "recharge", label: "部门总充值 ($)", type: "money" },
|
||||||
|
{ delta: 4.8, key: "turnover", label: "部门总流水 ($)", type: "money" },
|
||||||
|
{ delta: -1.8, key: "newUsers", label: "部门总新增", type: "number" },
|
||||||
|
{ delta: 2.9, key: "dau", label: "部门总日活 (DAU)", type: "number" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("department", "部门"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
textColumn("region", "负责地区"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
progressColumn("dayProgress", "当日目标完成进度"),
|
||||||
|
progressColumn("monthProgress", "当月目标完成进度")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "departments"],
|
||||||
|
key: "department",
|
||||||
|
label: "部门看板",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 14220,
|
||||||
|
dayProgress: progress(126800, 140000),
|
||||||
|
department: "拉美运营一部",
|
||||||
|
id: "dept-latam",
|
||||||
|
monthProgress: progress(3020000, 3600000),
|
||||||
|
newUsers: 516,
|
||||||
|
recharge: 126800,
|
||||||
|
region: "拉美大区",
|
||||||
|
turnover: 225400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 10540,
|
||||||
|
dayProgress: progress(96800, 90000),
|
||||||
|
department: "中东运营部",
|
||||||
|
id: "dept-mena",
|
||||||
|
monthProgress: progress(2416000, 2700000),
|
||||||
|
newUsers: 452,
|
||||||
|
recharge: 96800,
|
||||||
|
region: "中东大区",
|
||||||
|
turnover: 164300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 8160,
|
||||||
|
dayProgress: noGoal(),
|
||||||
|
department: "泛娱乐增长组",
|
||||||
|
id: "dept-growth",
|
||||||
|
monthProgress: progress(1682000, 2100000),
|
||||||
|
newUsers: 372,
|
||||||
|
recharge: 74200,
|
||||||
|
region: "东南亚大区",
|
||||||
|
turnover: 118900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "部门看板"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 6.5, key: "recharge", label: "人员充值合计 ($)", type: "money" },
|
||||||
|
{ delta: 4.4, key: "turnover", label: "人员流水合计 ($)", type: "money" },
|
||||||
|
{ delta: 2.1, key: "newUsers", label: "新增人数", type: "number" },
|
||||||
|
{ delta: -3.2, key: "monthKpiRate", label: "当月充值 KPI 达成率 (%)", type: "percent" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("operator", "运营人员"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
textColumn("region", "所管地区"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
progressColumn("dayProgress", "当日目标完成进度"),
|
||||||
|
progressColumn("monthProgress", "当月目标完成进度")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "operators"],
|
||||||
|
key: "personnel",
|
||||||
|
label: "人员绩效",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
appName: "Lalu",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: progress(52800, 50000),
|
||||||
|
id: "person-ava",
|
||||||
|
monthKpiRate: 88.4,
|
||||||
|
monthProgress: progress(1182000, 1350000),
|
||||||
|
newUsers: 211,
|
||||||
|
operator: "Ava Chen",
|
||||||
|
recharge: 52800,
|
||||||
|
region: "巴西 / 墨西哥",
|
||||||
|
turnover: 94100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Aslan",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: progress(41600, 45000),
|
||||||
|
id: "person-omar",
|
||||||
|
monthKpiRate: 82.1,
|
||||||
|
monthProgress: progress(906000, 1100000),
|
||||||
|
newUsers: 186,
|
||||||
|
operator: "Omar Saleh",
|
||||||
|
recharge: 41600,
|
||||||
|
region: "沙特 / 阿联酋",
|
||||||
|
turnover: 70400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appName: "Yumi",
|
||||||
|
date: "2026-06-30",
|
||||||
|
dayProgress: noGoal(),
|
||||||
|
id: "person-lina",
|
||||||
|
monthKpiRate: 75.6,
|
||||||
|
monthProgress: progress(642000, 850000),
|
||||||
|
newUsers: 154,
|
||||||
|
operator: "Lina Gomez",
|
||||||
|
recharge: 32600,
|
||||||
|
region: "菲律宾",
|
||||||
|
turnover: 52900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "人员绩效"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: 9.2, key: "recharge", label: "区域总充值 ($)", type: "money" },
|
||||||
|
{ delta: -1.1, key: "newUsers", label: "区域总新增", type: "number" },
|
||||||
|
{ delta: 2.4, key: "arppu", label: "区域综合 ARPPU ($)", type: "money" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("region", "地区"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
moneyColumn("recharge", "充值 ($)"),
|
||||||
|
moneyColumn("turnover", "流水 ($)"),
|
||||||
|
moneyColumn("arppu", "ARPPU ($)", "average"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活"),
|
||||||
|
percentColumn("retentionD1", "次留"),
|
||||||
|
percentColumn("retentionD7", "7日留"),
|
||||||
|
percentColumn("retentionD30", "30日留"),
|
||||||
|
moneyColumn("salary", "存量工资 ($)"),
|
||||||
|
numberColumn("coins", "金币数量"),
|
||||||
|
ratioColumn("agencyCount", "Agency数量"),
|
||||||
|
ratioColumn("hostCount", "Host数量"),
|
||||||
|
ratioColumn("bdCount", "BD数量"),
|
||||||
|
ratioColumn("coinSellerCount", "币商数量"),
|
||||||
|
numberColumn("coinSellerCoins", "币商金币数")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "regions"],
|
||||||
|
key: "regional",
|
||||||
|
label: "地区分析",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(84, 112),
|
||||||
|
appName: "Lalu",
|
||||||
|
arppu: 44.1,
|
||||||
|
bdCount: ratioCount(26, 34),
|
||||||
|
coinSellerCoins: 9840000,
|
||||||
|
coinSellerCount: ratioCount(18, 24),
|
||||||
|
coins: 36120000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 13280,
|
||||||
|
hostCount: ratioCount(412, 520),
|
||||||
|
id: "region-br",
|
||||||
|
newUsers: 506,
|
||||||
|
recharge: 206400,
|
||||||
|
region: "巴西",
|
||||||
|
retentionD1: 32.8,
|
||||||
|
retentionD7: 19.4,
|
||||||
|
retentionD30: 10.2,
|
||||||
|
salary: 52200,
|
||||||
|
turnover: 354200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(66, 95),
|
||||||
|
appName: "Aslan",
|
||||||
|
arppu: 41.5,
|
||||||
|
bdCount: ratioCount(19, 28),
|
||||||
|
coinSellerCoins: 7420000,
|
||||||
|
coinSellerCount: ratioCount(15, 21),
|
||||||
|
coins: 28450000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 11140,
|
||||||
|
hostCount: ratioCount(356, 480),
|
||||||
|
id: "region-sa",
|
||||||
|
newUsers: 442,
|
||||||
|
recharge: 173200,
|
||||||
|
region: "沙特",
|
||||||
|
retentionD1: 30.4,
|
||||||
|
retentionD7: 17.7,
|
||||||
|
retentionD30: 9.1,
|
||||||
|
salary: 44100,
|
||||||
|
turnover: 292600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyCount: ratioCount(41, 68),
|
||||||
|
appName: "Yumi",
|
||||||
|
arppu: 34.8,
|
||||||
|
bdCount: ratioCount(14, 22),
|
||||||
|
coinSellerCoins: 4180000,
|
||||||
|
coinSellerCount: ratioCount(9, 17),
|
||||||
|
coins: 17260000,
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 6840,
|
||||||
|
hostCount: ratioCount(228, 346),
|
||||||
|
id: "region-ph",
|
||||||
|
newUsers: 318,
|
||||||
|
recharge: 94800,
|
||||||
|
region: "菲律宾",
|
||||||
|
retentionD1: 27.5,
|
||||||
|
retentionD7: 14.9,
|
||||||
|
retentionD30: 7.3,
|
||||||
|
salary: 22600,
|
||||||
|
turnover: 164900
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "地区分析"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cards: [
|
||||||
|
{ delta: -1.7, key: "newUsers", label: "总新增", type: "number" },
|
||||||
|
{ delta: 3.5, key: "dau", label: "总日活", type: "number" },
|
||||||
|
{ delta: 1.2, key: "retentionD1", label: "次日留存均值 (%)", type: "percent" },
|
||||||
|
{ delta: 4.1, key: "ecosystemActive", label: "生态角色总活跃数", type: "number" }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
textColumn("date", "日期"),
|
||||||
|
textColumn("region", "地区"),
|
||||||
|
textColumn("appName", "App名称"),
|
||||||
|
numberColumn("newUsers", "新增"),
|
||||||
|
numberColumn("dau", "日活 (DAU)"),
|
||||||
|
percentColumn("retentionD1", "次日留存"),
|
||||||
|
percentColumn("retentionD7", "7日留存"),
|
||||||
|
percentColumn("retentionD30", "30日留存"),
|
||||||
|
ratioColumn("agencyActive", "Agency活跃数量"),
|
||||||
|
ratioColumn("hostActive", "Host活跃数量"),
|
||||||
|
ratioColumn("bdActive", "BD活跃数量"),
|
||||||
|
ratioColumn("coinSellerActive", "币商活跃数量")
|
||||||
|
],
|
||||||
|
filters: ["dateRange", "apps", "regions"],
|
||||||
|
key: "retention",
|
||||||
|
label: "留存分析",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(84, 112),
|
||||||
|
appName: "Lalu",
|
||||||
|
bdActive: ratioCount(26, 34),
|
||||||
|
coinSellerActive: ratioCount(18, 24),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 13280,
|
||||||
|
ecosystemActive: 540,
|
||||||
|
hostActive: ratioCount(412, 520),
|
||||||
|
id: "retain-br",
|
||||||
|
newUsers: 506,
|
||||||
|
region: "巴西",
|
||||||
|
retentionD1: 32.8,
|
||||||
|
retentionD7: 19.4,
|
||||||
|
retentionD30: 10.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(66, 95),
|
||||||
|
appName: "Aslan",
|
||||||
|
bdActive: ratioCount(19, 28),
|
||||||
|
coinSellerActive: ratioCount(15, 21),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 11140,
|
||||||
|
ecosystemActive: 456,
|
||||||
|
hostActive: ratioCount(356, 480),
|
||||||
|
id: "retain-sa",
|
||||||
|
newUsers: 442,
|
||||||
|
region: "沙特",
|
||||||
|
retentionD1: 30.4,
|
||||||
|
retentionD7: 17.7,
|
||||||
|
retentionD30: 9.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agencyActive: ratioCount(41, 68),
|
||||||
|
appName: "Yumi",
|
||||||
|
bdActive: ratioCount(14, 22),
|
||||||
|
coinSellerActive: ratioCount(9, 17),
|
||||||
|
date: "2026-06-30",
|
||||||
|
dau: 6840,
|
||||||
|
ecosystemActive: 292,
|
||||||
|
hostActive: ratioCount(228, 346),
|
||||||
|
id: "retain-ph",
|
||||||
|
newUsers: 318,
|
||||||
|
region: "菲律宾",
|
||||||
|
retentionD1: 27.5,
|
||||||
|
retentionD7: 14.9,
|
||||||
|
retentionD30: 7.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: "留存分析"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function textColumn(key, label) {
|
||||||
|
return { aggregate: "text", align: "left", key, label, type: "text" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyColumn(key, label, aggregate = "sum") {
|
||||||
|
return { aggregate, align: "right", key, label, type: "money" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberColumn(key, label) {
|
||||||
|
return { aggregate: "sum", align: "right", key, label, type: "number" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentColumn(key, label) {
|
||||||
|
return { aggregate: "average", align: "right", key, label, type: "percent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressColumn(key, label) {
|
||||||
|
return { aggregate: "progress", align: "left", key, label, type: "progress" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioColumn(key, label) {
|
||||||
|
return { aggregate: "ratio", align: "right", key, label, type: "ratio" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowRateColumn(key, label) {
|
||||||
|
return { aggregate: "flowRate", align: "right", key, label, type: "flowRate" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeOutputColumn(key, label) {
|
||||||
|
return { aggregate: "consumeOutput", align: "right", key, label, type: "consumeOutput" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationColumn(key, label, aggregate = "sum") {
|
||||||
|
return { aggregate: aggregate === "average" ? "durationAverage" : "duration", align: "right", key, label, type: "duration" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function progress(actual, target) {
|
||||||
|
return { actual, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function noGoal() {
|
||||||
|
return { actual: 0, noGoal: true, target: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioCount(active, total) {
|
||||||
|
return { active, total };
|
||||||
|
}
|
||||||
@ -6,4 +6,5 @@
|
|||||||
@import "./charts.css";
|
@import "./charts.css";
|
||||||
@import "./tables.css";
|
@import "./tables.css";
|
||||||
@import "./report.css";
|
@import "./report.css";
|
||||||
|
@import "./social-bi.css";
|
||||||
@import "./responsive.css";
|
@import "./responsive.css";
|
||||||
|
|||||||
@ -611,6 +611,39 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #126fb2;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 780;
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-link-button:hover {
|
||||||
|
color: #075d99;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-link-button:focus-visible {
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: 2px solid #1688d9;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-link-button--left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.report-country-cell {
|
.report-country-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
@ -711,6 +744,241 @@
|
|||||||
color: #8a98aa;
|
color: #8a98aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal {
|
||||||
|
display: grid;
|
||||||
|
width: min(960px, 100%);
|
||||||
|
max-height: min(760px, calc(100vh - 56px));
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 20px 56px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-head {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-head h2 {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 780;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-close,
|
||||||
|
.report-pagination button {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #c7d7e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #126fb2;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-close:hover,
|
||||||
|
.report-pagination button:hover:not(:disabled) {
|
||||||
|
border-color: #1688d9;
|
||||||
|
background: #eef7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pagination button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-body {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-filter {
|
||||||
|
align-items: center;
|
||||||
|
color: #65748a;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-filter select {
|
||||||
|
appearance: none;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #c7d7e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #253047;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-filter select:focus {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.12);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 640px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table th,
|
||||||
|
.report-modal-table td {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
color: #34445a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #65748a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table th:first-child,
|
||||||
|
.report-modal-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table tbody tr:hover td {
|
||||||
|
background: #f7fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table tbody tr.report-clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table tbody tr.report-clickable-row:hover td {
|
||||||
|
background: #eef7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-modal-table tbody tr.report-clickable-row:focus-visible td {
|
||||||
|
background: #e9f5ff;
|
||||||
|
outline: 2px solid #1688d9;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 260px;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell b,
|
||||||
|
.report-user-cell small,
|
||||||
|
.report-event-id {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell b {
|
||||||
|
color: #172033;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-user-cell small {
|
||||||
|
color: #8a98aa;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-event-id {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 240px;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #65748a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pagination > div {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pagination b {
|
||||||
|
color: #34445a;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
.report-pair-cell {
|
.report-pair-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 118px;
|
min-width: 118px;
|
||||||
|
|||||||
656
databi/src/styles/social-bi.css
Normal file
656
databi/src/styles/social-bi.css
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
.social-bi-shell {
|
||||||
|
--social-bi-control-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
min-width: 1040px;
|
||||||
|
min-height: 100vh;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-right: 1px solid #dfe8f2;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand span {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1688d9;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-brand strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f6f86;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item svg {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item span {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item:hover {
|
||||||
|
border-color: #e0ebf5;
|
||||||
|
background: #f4f8fc;
|
||||||
|
color: #34445a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item.is-active,
|
||||||
|
.social-bi-nav-item.is-active:hover {
|
||||||
|
border-color: #cfe3f5;
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-nav-item:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-field {
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown--wide {
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #718198;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f6f86;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button small {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8c9aab;
|
||||||
|
font-size: 9px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-date-options button:hover,
|
||||||
|
.social-bi-date-options button.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #126fb2;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
min-height: var(--social-bi-control-height);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 30px 0 10px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
list-style: none;
|
||||||
|
transition:
|
||||||
|
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
color: #8c9aab;
|
||||||
|
content: "⌄";
|
||||||
|
font-size: 16px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary:hover,
|
||||||
|
.social-bi-dropdown[open] summary {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary strong {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 720;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown summary .social-bi-field-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
display: grid;
|
||||||
|
width: min(320px, 90vw);
|
||||||
|
max-height: 330px;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown--wide .social-bi-dropdown-menu {
|
||||||
|
width: min(380px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu--search input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-menu--search input:focus {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px 0 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #34445a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
color 160ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option:hover,
|
||||||
|
.social-bi-option.is-selected {
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-option .MuiCheckbox-root {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-menu {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-cascade-children {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-dropdown-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: #718198;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root {
|
||||||
|
height: var(--social-bi-control-height);
|
||||||
|
min-height: var(--social-bi-control-height);
|
||||||
|
min-width: 84px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1688d9;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root:hover {
|
||||||
|
background: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-load-error {
|
||||||
|
align-self: center;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card {
|
||||||
|
display: grid;
|
||||||
|
min-height: 82px;
|
||||||
|
align-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card > span {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-card strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta {
|
||||||
|
width: max-content;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta.is-up {
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-delta.is-down {
|
||||||
|
background: #fff1f2;
|
||||||
|
color: #be123c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-section {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-head {
|
||||||
|
display: flex;
|
||||||
|
height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid #e4ebf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-head span {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1120px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th,
|
||||||
|
.social-bi-table td {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
color: #34445a;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th {
|
||||||
|
background: #f8fbfe;
|
||||||
|
color: #5f6f86;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th.is-left,
|
||||||
|
.social-bi-table td.is-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th button {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th.is-left button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table th button:hover,
|
||||||
|
.social-bi-table th button.is-active {
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-sort-arrow {
|
||||||
|
color: #8c9aab;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-table tr.is-total td {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f0f7ff;
|
||||||
|
color: #172033;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 86px;
|
||||||
|
gap: 2px;
|
||||||
|
justify-items: end;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-pair-cell small {
|
||||||
|
color: #718198;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-cell {
|
||||||
|
display: grid;
|
||||||
|
min-width: 150px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-copy span {
|
||||||
|
color: #126fb2;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-track {
|
||||||
|
height: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-progress-track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #1688d9, #1fbf75);
|
||||||
|
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-goal-empty {
|
||||||
|
color: #8c9aab;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-empty-row td {
|
||||||
|
height: 64px;
|
||||||
|
color: #718198;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
animation: social-bi-shimmer 1.15s infinite;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75), transparent);
|
||||||
|
content: "";
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--title {
|
||||||
|
width: 54%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--value {
|
||||||
|
width: 74%;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--line {
|
||||||
|
width: 42%;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-skeleton--cell {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes social-bi-shimmer {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.social-bi-summary-section {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.social-bi-shell {
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-filter-section {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-field,
|
||||||
|
.social-bi-date-field,
|
||||||
|
.social-bi-dropdown--wide {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-query-button.MuiButton-root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-bi-summary-section {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.social-bi-shell *,
|
||||||
|
.social-bi-shell *::before,
|
||||||
|
.social-bi-shell *::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
transition-duration: 1ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
finance/index.html
Normal file
12
finance/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="finance-root"></div>
|
||||||
|
<script type="module" src="/finance/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
444
finance/src/FinanceApp.jsx
Normal file
444
finance/src/FinanceApp.jsx
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import {
|
||||||
|
approveFinanceApplication,
|
||||||
|
approveFinanceWithdrawalApplication,
|
||||||
|
createFinanceApplication,
|
||||||
|
fetchFinanceApplicationOptions,
|
||||||
|
fetchFinanceSession,
|
||||||
|
filterOperationsByPermissions,
|
||||||
|
listFinanceApplications,
|
||||||
|
listFinanceRechargeDetails,
|
||||||
|
listMyFinanceApplications,
|
||||||
|
listFinanceWithdrawalApplications,
|
||||||
|
rejectFinanceApplication,
|
||||||
|
rejectFinanceWithdrawalApplication
|
||||||
|
} from "./api.js";
|
||||||
|
import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx";
|
||||||
|
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
|
||||||
|
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
|
||||||
|
import { FinanceMyApplicationList } from "./components/FinanceMyApplicationList.jsx";
|
||||||
|
import { FinanceRechargeDetailList } from "./components/FinanceRechargeDetailList.jsx";
|
||||||
|
import { FinanceShell } from "./components/FinanceShell.jsx";
|
||||||
|
import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx";
|
||||||
|
import { DEFAULT_APPLICATION_FORM } from "./constants.js";
|
||||||
|
import { buildApplicationPayload, validateApplicationPayload } from "./format.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const emptyOptions = { apps: [], operations: [], walletIdentities: [] };
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
export function FinanceApp() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||||
|
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
||||||
|
const [rechargeDetailsState, setRechargeDetailsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||||
|
const [myApplicationsState, setMyApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||||
|
const [applicationsState, setApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||||
|
const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false });
|
||||||
|
const [activeView, setActiveView] = useState("create");
|
||||||
|
const [rechargeDetailFilters, setRechargeDetailFilters] = useState({ appCode: "", keyword: "", page: 1, timeRange: { endMs: "", startMs: "" } });
|
||||||
|
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
|
||||||
|
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
|
||||||
|
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
|
||||||
|
const [form, setForm] = useState(DEFAULT_APPLICATION_FORM);
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [auditTarget, setAuditTarget] = useState(null);
|
||||||
|
const [auditLoading, setAuditLoading] = useState(false);
|
||||||
|
const [withdrawalAuditTarget, setWithdrawalAuditTarget] = useState(null);
|
||||||
|
const [withdrawalAuditLoading, setWithdrawalAuditLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
fetchFinanceSession()
|
||||||
|
.then((session) => {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSessionState({ error: "", loading: false, session });
|
||||||
|
setActiveView(
|
||||||
|
session.canViewAppRechargeDetails
|
||||||
|
? "rechargeDetails"
|
||||||
|
: session.canAuditApplication
|
||||||
|
? "applications"
|
||||||
|
: session.canCreateApplication
|
||||||
|
? "create"
|
||||||
|
: "withdrawals"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (mounted) {
|
||||||
|
setSessionState({ error: err.message || "登录已失效", loading: false, session: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const session = sessionState.session;
|
||||||
|
const canLoadOptions = Boolean(session?.canAccessFinance);
|
||||||
|
const rechargeDetailQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
app_code: rechargeDetailFilters.appCode,
|
||||||
|
end_at_ms: rechargeDetailFilters.timeRange.endMs,
|
||||||
|
keyword: rechargeDetailFilters.keyword,
|
||||||
|
page: rechargeDetailFilters.page,
|
||||||
|
page_size: pageSize,
|
||||||
|
start_at_ms: rechargeDetailFilters.timeRange.startMs,
|
||||||
|
stat_tz: "Asia/Shanghai",
|
||||||
|
status: "succeeded"
|
||||||
|
}),
|
||||||
|
[rechargeDetailFilters]
|
||||||
|
);
|
||||||
|
const myApplicationQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
app_code: myFilters.appCode,
|
||||||
|
keyword: myFilters.keyword,
|
||||||
|
operation: myFilters.operation,
|
||||||
|
page: myFilters.page,
|
||||||
|
page_size: pageSize,
|
||||||
|
status: myFilters.status
|
||||||
|
}),
|
||||||
|
[myFilters]
|
||||||
|
);
|
||||||
|
const applicationQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
app_code: filters.appCode,
|
||||||
|
keyword: filters.keyword,
|
||||||
|
operation: filters.operation,
|
||||||
|
page: filters.page,
|
||||||
|
page_size: pageSize,
|
||||||
|
status: filters.status
|
||||||
|
}),
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
const withdrawalQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
app_code: withdrawalFilters.appCode,
|
||||||
|
keyword: withdrawalFilters.keyword,
|
||||||
|
page: withdrawalFilters.page,
|
||||||
|
page_size: pageSize
|
||||||
|
}),
|
||||||
|
[withdrawalFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOptions = useCallback(async () => {
|
||||||
|
if (!canLoadOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOptionsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await fetchFinanceApplicationOptions();
|
||||||
|
setOptionsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setOptionsState({ data: emptyOptions, error: err.message || "加载财务选项失败", loading: false });
|
||||||
|
}
|
||||||
|
}, [canLoadOptions]);
|
||||||
|
|
||||||
|
const loadMyApplications = useCallback(async () => {
|
||||||
|
if (!session?.canCreateApplication) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMyApplicationsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await listMyFinanceApplications(myApplicationQuery);
|
||||||
|
setMyApplicationsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setMyApplicationsState((current) => ({ ...current, error: err.message || "加载我的申请列表失败", loading: false }));
|
||||||
|
}
|
||||||
|
}, [myApplicationQuery, session?.canCreateApplication]);
|
||||||
|
|
||||||
|
const loadRechargeDetails = useCallback(async () => {
|
||||||
|
if (!session?.canViewAppRechargeDetails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRechargeDetailsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await listFinanceRechargeDetails(rechargeDetailQuery, optionsState.data.apps);
|
||||||
|
setRechargeDetailsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载 APP 充值详情失败", loading: false }));
|
||||||
|
}
|
||||||
|
}, [optionsState.data.apps, rechargeDetailQuery, session?.canViewAppRechargeDetails]);
|
||||||
|
|
||||||
|
const loadApplications = useCallback(async () => {
|
||||||
|
if (!session?.canAuditApplication) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApplicationsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await listFinanceApplications(applicationQuery);
|
||||||
|
setApplicationsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setApplicationsState((current) => ({ ...current, error: err.message || "加载申请列表失败", loading: false }));
|
||||||
|
}
|
||||||
|
}, [applicationQuery, session?.canAuditApplication]);
|
||||||
|
|
||||||
|
const loadWithdrawals = useCallback(async () => {
|
||||||
|
if (!session?.canViewWithdrawalApplications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWithdrawalsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await listFinanceWithdrawalApplications(withdrawalQuery);
|
||||||
|
setWithdrawalsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setWithdrawalsState((current) => ({ ...current, error: err.message || "加载提现申请列表失败", loading: false }));
|
||||||
|
}
|
||||||
|
}, [session?.canViewWithdrawalApplications, withdrawalQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOptions();
|
||||||
|
}, [loadOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMyApplications();
|
||||||
|
}, [loadMyApplications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRechargeDetails();
|
||||||
|
}, [loadRechargeDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApplications();
|
||||||
|
}, [loadApplications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWithdrawals();
|
||||||
|
}, [loadWithdrawals]);
|
||||||
|
|
||||||
|
const operationOptions = useMemo(
|
||||||
|
() => filterOperationsByPermissions(optionsState.data.operations, session?.permissions || []),
|
||||||
|
[optionsState.data.operations, session?.permissions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitApplication = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!session?.canCreateApplication) {
|
||||||
|
showToast("无发起财务申请权限", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildApplicationPayload(form);
|
||||||
|
const validationMessage = validateApplicationPayload(payload);
|
||||||
|
if (validationMessage) {
|
||||||
|
showToast(validationMessage, "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await createFinanceApplication(cleanPayload(payload));
|
||||||
|
setForm({ ...DEFAULT_APPLICATION_FORM, appCode: payload.appCode });
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
showToast(result?.dingTalkNotified === false ? "申请已提交,钉钉通知未确认" : "申请已提交并通知财务", "success");
|
||||||
|
await loadMyApplications();
|
||||||
|
if (session.canAuditApplication) {
|
||||||
|
await loadApplications();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "发起申请失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAudit = async ({ decision, remark }) => {
|
||||||
|
if (!auditTarget?.id || !session?.canAuditApplication) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuditLoading(true);
|
||||||
|
try {
|
||||||
|
const submitter = decision === "approved" ? approveFinanceApplication : rejectFinanceApplication;
|
||||||
|
await submitter(auditTarget.id, { auditRemark: remark });
|
||||||
|
showToast(decision === "approved" ? "申请已通过" : "申请已拒绝", "success");
|
||||||
|
setAuditTarget(null);
|
||||||
|
await loadApplications();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "审核失败", "error");
|
||||||
|
} finally {
|
||||||
|
setAuditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => {
|
||||||
|
if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWithdrawalAuditLoading(true);
|
||||||
|
try {
|
||||||
|
const submitter = decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication;
|
||||||
|
await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark }));
|
||||||
|
showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success");
|
||||||
|
setWithdrawalAuditTarget(null);
|
||||||
|
await loadWithdrawals();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "审核提现申请失败", "error");
|
||||||
|
} finally {
|
||||||
|
setWithdrawalAuditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFilters = (nextFilters) => {
|
||||||
|
setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRechargeDetailFilters = (nextFilters) => {
|
||||||
|
setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMyFilters = (nextFilters) => {
|
||||||
|
setMyFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWithdrawalFilters = (nextFilters) => {
|
||||||
|
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCreateDialog = (_, reason) => {
|
||||||
|
if (submitting || reason === "backdropClick") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionState.loading) {
|
||||||
|
return <StateScreen title="正在进入财务系统" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState.error) {
|
||||||
|
return <StateScreen actionHref="/login" actionText="重新登录" title={sessionState.error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.canAccessFinance) {
|
||||||
|
return <StateScreen title="无财务系统权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FinanceShell
|
||||||
|
activeView={activeView}
|
||||||
|
canAudit={session.canAuditApplication}
|
||||||
|
canCreate={session.canCreateApplication}
|
||||||
|
canViewRechargeDetails={session.canViewAppRechargeDetails}
|
||||||
|
canViewWithdrawals={session.canViewWithdrawalApplications}
|
||||||
|
loading={optionsState.loading || rechargeDetailsState.loading || myApplicationsState.loading || applicationsState.loading || withdrawalsState.loading}
|
||||||
|
session={session}
|
||||||
|
onRefresh={() => {
|
||||||
|
loadOptions();
|
||||||
|
loadRechargeDetails();
|
||||||
|
loadMyApplications();
|
||||||
|
loadApplications();
|
||||||
|
loadWithdrawals();
|
||||||
|
}}
|
||||||
|
onViewChange={setActiveView}
|
||||||
|
>
|
||||||
|
{activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? (
|
||||||
|
<FinanceRechargeDetailList
|
||||||
|
bills={rechargeDetailsState.data}
|
||||||
|
error={rechargeDetailsState.error}
|
||||||
|
filters={rechargeDetailFilters}
|
||||||
|
loading={rechargeDetailsState.loading}
|
||||||
|
options={optionsState.data}
|
||||||
|
onFiltersChange={updateRechargeDetailFilters}
|
||||||
|
onReload={loadRechargeDetails}
|
||||||
|
/>
|
||||||
|
) : activeView === "applications" && session.canAuditApplication ? (
|
||||||
|
<FinanceApplicationList
|
||||||
|
applications={applicationsState.data}
|
||||||
|
error={applicationsState.error}
|
||||||
|
filters={filters}
|
||||||
|
loading={applicationsState.loading}
|
||||||
|
operations={optionsState.data.operations}
|
||||||
|
options={optionsState.data}
|
||||||
|
onAudit={setAuditTarget}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
|
onReload={loadApplications}
|
||||||
|
/>
|
||||||
|
) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? (
|
||||||
|
<FinanceWithdrawalApplicationList
|
||||||
|
applications={withdrawalsState.data}
|
||||||
|
canAudit={session.canAuditWithdrawalApplications}
|
||||||
|
error={withdrawalsState.error}
|
||||||
|
filters={withdrawalFilters}
|
||||||
|
loading={withdrawalsState.loading}
|
||||||
|
options={optionsState.data}
|
||||||
|
onAudit={setWithdrawalAuditTarget}
|
||||||
|
onFiltersChange={updateWithdrawalFilters}
|
||||||
|
onReload={loadWithdrawals}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FinanceMyApplicationList
|
||||||
|
applications={myApplicationsState.data}
|
||||||
|
error={myApplicationsState.error}
|
||||||
|
filters={myFilters}
|
||||||
|
loading={myApplicationsState.loading}
|
||||||
|
operations={optionsState.data.operations}
|
||||||
|
options={optionsState.data}
|
||||||
|
onCreate={() => setCreateDialogOpen(true)}
|
||||||
|
onFiltersChange={updateMyFilters}
|
||||||
|
onReload={loadMyApplications}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
open={createDialogOpen}
|
||||||
|
slotProps={{ paper: { className: "finance-create-dialog-paper" } }}
|
||||||
|
onClose={closeCreateDialog}
|
||||||
|
>
|
||||||
|
<DialogTitle>发起申请</DialogTitle>
|
||||||
|
<DialogContent className="finance-create-dialog">
|
||||||
|
<FinanceApplicationForm
|
||||||
|
apps={optionsState.data.apps}
|
||||||
|
disabled={!session.canCreateApplication}
|
||||||
|
error={optionsState.error}
|
||||||
|
form={form}
|
||||||
|
loading={optionsState.loading || submitting}
|
||||||
|
operations={operationOptions}
|
||||||
|
walletIdentities={optionsState.data.walletIdentities}
|
||||||
|
onCancel={closeCreateDialog}
|
||||||
|
onChange={setForm}
|
||||||
|
onSubmit={submitApplication}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<FinanceAuditDialog
|
||||||
|
application={auditTarget}
|
||||||
|
loading={auditLoading}
|
||||||
|
operations={optionsState.data.operations}
|
||||||
|
open={Boolean(auditTarget)}
|
||||||
|
onClose={() => setAuditTarget(null)}
|
||||||
|
onSubmit={submitAudit}
|
||||||
|
/>
|
||||||
|
<FinanceAuditDialog
|
||||||
|
application={withdrawalAuditTarget}
|
||||||
|
loading={withdrawalAuditLoading}
|
||||||
|
operations={optionsState.data.operations}
|
||||||
|
open={Boolean(withdrawalAuditTarget)}
|
||||||
|
requireApprovalImage
|
||||||
|
requireRejectRemark
|
||||||
|
onClose={() => setWithdrawalAuditTarget(null)}
|
||||||
|
onSubmit={submitWithdrawalAudit}
|
||||||
|
/>
|
||||||
|
</FinanceShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateScreen({ actionHref = "", actionText = "", title }) {
|
||||||
|
return (
|
||||||
|
<main className="finance-state-screen">
|
||||||
|
<section className="finance-state-panel">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{actionHref ? <a href={actionHref}>{actionText}</a> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPayload(payload) {
|
||||||
|
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== ""));
|
||||||
|
}
|
||||||
55
finance/src/FinanceApp.test.jsx
Normal file
55
finance/src/FinanceApp.test.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { FinanceApp } from "./FinanceApp.jsx";
|
||||||
|
|
||||||
|
vi.mock("./api.js", () => ({
|
||||||
|
approveFinanceApplication: vi.fn(),
|
||||||
|
approveFinanceWithdrawalApplication: vi.fn(),
|
||||||
|
createFinanceApplication: vi.fn(),
|
||||||
|
fetchFinanceApplicationOptions: vi.fn(async () => ({
|
||||||
|
apps: [{ appCode: "lalu", appName: "lalu" }],
|
||||||
|
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
|
||||||
|
walletIdentities: []
|
||||||
|
})),
|
||||||
|
fetchFinanceSession: vi.fn(async () => ({
|
||||||
|
canAccessFinance: true,
|
||||||
|
canAuditApplication: false,
|
||||||
|
canCreateApplication: true,
|
||||||
|
canViewAppRechargeDetails: false,
|
||||||
|
canViewWithdrawalApplications: false,
|
||||||
|
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
|
||||||
|
user: { account: "admin", name: "admin" }
|
||||||
|
})),
|
||||||
|
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
||||||
|
listFinanceApplications: vi.fn(),
|
||||||
|
listFinanceRechargeDetails: vi.fn(),
|
||||||
|
listFinanceWithdrawalApplications: vi.fn(),
|
||||||
|
listMyFinanceApplications: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
|
||||||
|
rejectFinanceApplication: vi.fn(),
|
||||||
|
rejectFinanceWithdrawalApplication: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps create application dialog open when backdrop is clicked", async () => {
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "添加" }));
|
||||||
|
expect(within(screen.getByRole("dialog")).getByRole("heading", { name: "发起申请" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||||
|
expect(backdrop).toBeTruthy();
|
||||||
|
fireEvent.mouseDown(backdrop);
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "取消" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
217
finance/src/api.js
Normal file
217
finance/src/api.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { getMe, refreshSession } from "@/features/auth/api";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
||||||
|
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
||||||
|
import { normalizeApplicationPage, normalizeRechargeBillPage, normalizeWithdrawalApplicationPage } from "./format.js";
|
||||||
|
|
||||||
|
const appCodeHeader = "X-App-Code";
|
||||||
|
|
||||||
|
export async function fetchFinanceSession() {
|
||||||
|
const session = await currentSession();
|
||||||
|
const permissions = Array.isArray(session.permissions) ? session.permissions : [];
|
||||||
|
const canCreateApplication = permissions.includes(FINANCE_PERMISSIONS.createApplication);
|
||||||
|
const canAuditApplication = permissions.includes(FINANCE_PERMISSIONS.auditApplication);
|
||||||
|
const canAuditWithdrawalApplications =
|
||||||
|
permissions.includes(FINANCE_PERMISSIONS.auditWithdrawalApplications) || permissions.includes(FINANCE_PERMISSIONS.auditApplication);
|
||||||
|
const canViewWithdrawalApplications = permissions.includes(FINANCE_PERMISSIONS.viewWithdrawalApplications) || canAuditWithdrawalApplications;
|
||||||
|
const canViewAppRechargeDetails = permissions.includes(FINANCE_PERMISSIONS.viewAppRechargeDetails);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
canAccessFinance: canCreateApplication || canAuditApplication || canViewWithdrawalApplications || canViewAppRechargeDetails,
|
||||||
|
canAuditApplication,
|
||||||
|
canAuditWithdrawalApplications,
|
||||||
|
canCreateApplication,
|
||||||
|
canViewAppRechargeDetails,
|
||||||
|
canViewWithdrawalApplications,
|
||||||
|
permissions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFinanceApplicationOptions() {
|
||||||
|
const endpoint = API_ENDPOINTS.listApps;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listApps), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
return normalizeOptions({ apps: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFinanceApplications(query = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listFinanceApplications;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceApplications), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
return normalizeApplicationPage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMyFinanceApplications(query = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listMyFinanceApplications;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listMyFinanceApplications), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
return normalizeApplicationPage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFinanceWithdrawalApplications(query = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listFinanceWithdrawalApplications;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceWithdrawalApplications), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
return normalizeWithdrawalApplicationPage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFinanceRechargeDetails(query = {}, apps = []) {
|
||||||
|
const page = positiveInteger(query.page, 1);
|
||||||
|
const pageSize = positiveInteger(query.page_size ?? query.pageSize, 50);
|
||||||
|
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||||
|
const cleanQuery = cleanRechargeBillQuery(query);
|
||||||
|
|
||||||
|
if (selectedAppCode) {
|
||||||
|
const data = await listRechargeBillsForApp(selectedAppCode, { ...cleanQuery, page, page_size: pageSize });
|
||||||
|
return { ...data, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
||||||
|
if (!appCodes.length) {
|
||||||
|
return { items: [], page, pageSize, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPageSize = page * pageSize;
|
||||||
|
const pages = await Promise.all(
|
||||||
|
appCodes.map((appCode) => listRechargeBillsForApp(appCode, { ...cleanQuery, page: 1, page_size: mergedPageSize }))
|
||||||
|
);
|
||||||
|
const items = pages
|
||||||
|
.flatMap((data) => data.items || [])
|
||||||
|
.sort((left, right) => (right.createdAtMs || 0) - (left.createdAtMs || 0) || String(right.transactionId).localeCompare(String(left.transactionId)));
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.slice(start, start + pageSize),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: pages.reduce((sum, data) => sum + Number(data.total || 0), 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFinanceApplication(payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.createFinanceApplication;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceApplication), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveFinanceApplication(applicationId, payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.approveFinanceApplication;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceApplication, { application_id: applicationId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectFinanceApplication(applicationId, payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.rejectFinanceApplication;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceApplication, { application_id: applicationId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveFinanceWithdrawalApplication(applicationId, payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectFinanceWithdrawalApplication(applicationId, payload) {
|
||||||
|
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
|
||||||
|
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOperationsByPermissions(operations, permissions) {
|
||||||
|
const allowed = new Set(permissions || []);
|
||||||
|
return (operations || []).filter((operation) => !operation.permissionCode || allowed.has(operation.permissionCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentSession() {
|
||||||
|
try {
|
||||||
|
return await refreshSession();
|
||||||
|
} catch {
|
||||||
|
if (getAccessToken()) {
|
||||||
|
return getMe();
|
||||||
|
}
|
||||||
|
throw new Error("登录已失效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRechargeBillsForApp(appCode, query = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||||
|
headers: { [appCodeHeader]: appCode },
|
||||||
|
method: endpoint.method,
|
||||||
|
query
|
||||||
|
});
|
||||||
|
return normalizeRechargeBillPage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(data = {}) {
|
||||||
|
return {
|
||||||
|
apps: normalizeApps(data.apps),
|
||||||
|
operations: FINANCE_OPERATIONS,
|
||||||
|
walletIdentities: WALLET_IDENTITIES
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApps(data) {
|
||||||
|
return listItems(data)
|
||||||
|
.map((item) => {
|
||||||
|
const appCode = stringValue(item.appCode ?? item.app_code ?? item.code);
|
||||||
|
if (!appCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appCode,
|
||||||
|
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listItems(data) {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data?.items)) {
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRechargeBillQuery(query = {}) {
|
||||||
|
const {
|
||||||
|
app_code: _appCode,
|
||||||
|
appCode: _camelAppCode,
|
||||||
|
page: _page,
|
||||||
|
page_size: _pageSize,
|
||||||
|
pageSize: _camelPageSize,
|
||||||
|
...rest
|
||||||
|
} = query;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value, fallback) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
153
finance/src/api.test.js
Normal file
153
finance/src/api.test.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
|
import {
|
||||||
|
approveFinanceApplication,
|
||||||
|
approveFinanceWithdrawalApplication,
|
||||||
|
createFinanceApplication,
|
||||||
|
fetchFinanceApplicationOptions,
|
||||||
|
filterOperationsByPermissions,
|
||||||
|
listFinanceApplications,
|
||||||
|
listFinanceRechargeDetails,
|
||||||
|
listMyFinanceApplications,
|
||||||
|
listFinanceWithdrawalApplications,
|
||||||
|
rejectFinanceApplication,
|
||||||
|
rejectFinanceWithdrawalApplication
|
||||||
|
} from "./api.js";
|
||||||
|
import { FINANCE_OPERATIONS } from "./constants.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAccessToken("");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finance application APIs use generated endpoint paths", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url) => {
|
||||||
|
if (String(url).includes("/admin/apps")) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data: { items: [{ appCode: "lalu", appName: "lalu" }], total: 1 } }));
|
||||||
|
}
|
||||||
|
if (String(url).includes("/applications?")) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||||
|
}
|
||||||
|
if (String(url).includes("/applications/mine?")) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||||
|
}
|
||||||
|
if (String(url).includes("/withdrawal-applications?")) {
|
||||||
|
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ code: 0, data: { id: 12 } }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = await fetchFinanceApplicationOptions();
|
||||||
|
await listFinanceApplications({ page: 1, page_size: 50, status: "pending" });
|
||||||
|
await listMyFinanceApplications({ page: 1, page_size: 50 });
|
||||||
|
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
|
||||||
|
await createFinanceApplication({
|
||||||
|
appCode: "lalu",
|
||||||
|
coinAmount: 100,
|
||||||
|
operation: "coin_seller_coin_credit",
|
||||||
|
rechargeAmount: 10,
|
||||||
|
targetUserId: "10001"
|
||||||
|
});
|
||||||
|
await approveFinanceApplication(12, { auditRemark: "ok" });
|
||||||
|
await rejectFinanceApplication(13, { auditRemark: "bad" });
|
||||||
|
await approveFinanceWithdrawalApplication(14, { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||||
|
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" });
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(options.apps).toEqual([{ appCode: "lalu", appName: "lalu" }]);
|
||||||
|
expect(options.operations.map((item) => item.label)).toEqual([
|
||||||
|
"增加用户金币",
|
||||||
|
"减少用户金币",
|
||||||
|
"用户钱包扣减",
|
||||||
|
"用户钱包增加",
|
||||||
|
"增加币商金币",
|
||||||
|
"减少币商金币"
|
||||||
|
]);
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/admin/apps");
|
||||||
|
expect(calls[0][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/applications?");
|
||||||
|
expect(String(calls[1][0])).toContain("status=pending");
|
||||||
|
expect(calls[1][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/applications/mine?");
|
||||||
|
expect(calls[2][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications?");
|
||||||
|
expect(calls[3][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[4][0])).toContain("/api/v1/admin/finance/applications");
|
||||||
|
expect(calls[4][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[4][1]?.body))).toMatchObject({ appCode: "lalu", operation: "coin_seller_coin_credit" });
|
||||||
|
expect(String(calls[5][0])).toContain("/api/v1/admin/finance/applications/12/approve");
|
||||||
|
expect(calls[5][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[5][1]?.body))).toEqual({ auditRemark: "ok" });
|
||||||
|
expect(String(calls[6][0])).toContain("/api/v1/admin/finance/applications/13/reject");
|
||||||
|
expect(calls[6][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[6][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||||
|
expect(String(calls[7][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve");
|
||||||
|
expect(calls[7][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[7][1]?.body))).toEqual({ auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||||
|
expect(String(calls[8][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
|
||||||
|
expect(calls[8][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[8][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters operation options by backend-configured permissions", () => {
|
||||||
|
const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [
|
||||||
|
"finance-operation:user-coin-credit",
|
||||||
|
"finance-operation:coin-seller-coin-debit"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(allowed.map((item) => item.value)).toEqual(["user_coin_credit", "coin_seller_coin_debit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finance recharge details query every app with app header and merge by recharge time", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url, init) => {
|
||||||
|
const appCode = init?.headers?.["X-App-Code"];
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode,
|
||||||
|
coinAmount: appCode === "aslan" ? 2000 : 1000,
|
||||||
|
createdAtMs: appCode === "aslan" ? 1760003600000 : 1760000000000,
|
||||||
|
exchangeCoinAmount: appCode === "aslan" ? 2000 : 1000,
|
||||||
|
exchangeUsdMinorAmount: appCode === "aslan" ? 200 : 100,
|
||||||
|
rechargeType: "google_play_recharge",
|
||||||
|
status: "succeeded",
|
||||||
|
transactionId: `${appCode}-tx`,
|
||||||
|
usdMinorAmount: appCode === "aslan" ? 200 : 100,
|
||||||
|
userId: "10001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 2,
|
||||||
|
total: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = await listFinanceRechargeDetails(
|
||||||
|
{ page: 1, page_size: 2, start_at_ms: 1760000000000, status: "succeeded" },
|
||||||
|
[
|
||||||
|
{ appCode: "lalu", appName: "lalu" },
|
||||||
|
{ appCode: "aslan", appName: "aslan" }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(page.total).toBe(2);
|
||||||
|
expect(page.items.map((item) => item.transactionId)).toEqual(["aslan-tx", "lalu-tx"]);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(calls.map(([, init]) => init?.headers?.["X-App-Code"])).toEqual(["lalu", "aslan"]);
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/admin/payment/recharge-bills?");
|
||||||
|
expect(String(calls[0][0])).toContain("page_size=2");
|
||||||
|
expect(String(calls[0][0])).toContain("status=succeeded");
|
||||||
|
expect(String(calls[0][0])).toContain("start_at_ms=1760000000000");
|
||||||
|
});
|
||||||
142
finance/src/components/FinanceApplicationForm.jsx
Normal file
142
finance/src/components/FinanceApplicationForm.jsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
|
|
||||||
|
export function FinanceApplicationForm({
|
||||||
|
apps,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
operations,
|
||||||
|
walletIdentities,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
onSubmit
|
||||||
|
}) {
|
||||||
|
const selectedOperation = operations.find((operation) => operation.value === form.operation);
|
||||||
|
const needsWalletIdentity = Boolean(selectedOperation?.requiresWalletIdentity);
|
||||||
|
const canSubmit = !disabled && !loading && apps.length > 0 && operations.length > 0;
|
||||||
|
|
||||||
|
const updateField = (key, value) => {
|
||||||
|
if (key === "operation") {
|
||||||
|
const nextOperation = operations.find((operation) => operation.value === value);
|
||||||
|
onChange({ ...form, operation: value, walletIdentity: nextOperation?.requiresWalletIdentity ? form.walletIdentity : "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ ...form, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={onCancel ? "finance-form finance-form--dialog" : "finance-panel finance-form"} onSubmit={onSubmit}>
|
||||||
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
|
{disabled ? <Alert severity="warning">无发起财务申请权限</Alert> : null}
|
||||||
|
{!loading && !disabled && (!apps.length || !operations.length) ? <Alert severity="warning">暂无可用 APP 或操作权限</Alert> : null}
|
||||||
|
<div className="finance-form-grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label="APP"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.appCode}
|
||||||
|
onChange={(event) => updateField("appCode", event.target.value)}
|
||||||
|
>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label="操作内容"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.operation}
|
||||||
|
onChange={(event) => updateField("operation", event.target.value)}
|
||||||
|
>
|
||||||
|
{operations.map((operation) => (
|
||||||
|
<MenuItem key={operation.value} value={operation.value}>
|
||||||
|
{operation.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{needsWalletIdentity ? (
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label="钱包身份"
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.walletIdentity}
|
||||||
|
onChange={(event) => updateField("walletIdentity", event.target.value)}
|
||||||
|
>
|
||||||
|
{walletIdentities.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label="目标用户 ID"
|
||||||
|
required
|
||||||
|
value={form.targetUserId}
|
||||||
|
onChange={(event) => updateField("targetUserId", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
inputProps={{ min: 0, step: "1" }}
|
||||||
|
label="金币数量"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={form.coinAmount}
|
||||||
|
onChange={(event) => updateField("coinAmount", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
||||||
|
inputProps={{ min: 0, step: "0.01" }}
|
||||||
|
label="充值金额"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={form.rechargeAmount}
|
||||||
|
onChange={(event) => updateField("rechargeAmount", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="finance-evidence-grid">
|
||||||
|
<UploadField
|
||||||
|
className="finance-credential-upload"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
hideType
|
||||||
|
label="凭证图片"
|
||||||
|
previewClassName="finance-credential-preview"
|
||||||
|
value={form.credentialImageUrl}
|
||||||
|
onChange={(value) => updateField("credentialImageUrl", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || loading}
|
||||||
|
label="凭证文字"
|
||||||
|
minRows={7}
|
||||||
|
multiline
|
||||||
|
value={form.credentialText}
|
||||||
|
onChange={(event) => updateField("credentialText", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="finance-form-actions">
|
||||||
|
{onCancel ? (
|
||||||
|
<Button disabled={loading} variant="outlined" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button disabled={!canSubmit} startIcon={<SendOutlined fontSize="small" />} type="submit" variant="contained">
|
||||||
|
发起申请
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
finance/src/components/FinanceApplicationList.jsx
Normal file
193
finance/src/components/FinanceApplicationList.jsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||||
|
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { APPLICATION_STATUS } from "../constants.js";
|
||||||
|
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
|
||||||
|
|
||||||
|
export function FinanceApplicationList({ applications, error, filters, loading, onAudit, onFiltersChange, onReload, operations, options }) {
|
||||||
|
const items = applications.items || [];
|
||||||
|
const page = Number(applications.page || filters.page || 1);
|
||||||
|
const pageSize = Number(applications.pageSize || 50);
|
||||||
|
const total = Number(applications.total || 0);
|
||||||
|
const hasNextPage = page * pageSize < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="finance-panel finance-list">
|
||||||
|
<div className="finance-toolbar">
|
||||||
|
<TextField
|
||||||
|
label="APP"
|
||||||
|
select
|
||||||
|
value={filters.appCode}
|
||||||
|
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部 APP</MenuItem>
|
||||||
|
{options.apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="操作内容"
|
||||||
|
select
|
||||||
|
value={filters.operation}
|
||||||
|
onChange={(event) => onFiltersChange({ operation: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部操作</MenuItem>
|
||||||
|
{operations.map((operation) => (
|
||||||
|
<MenuItem key={operation.value} value={operation.value}>
|
||||||
|
{operation.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="状态" select value={filters.status} onChange={(event) => onFiltersChange({ status: event.target.value })}>
|
||||||
|
<MenuItem value="">全部状态</MenuItem>
|
||||||
|
{APPLICATION_STATUS.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="搜索"
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
|
刷新列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
|
{!loading || items.length ? (
|
||||||
|
<TableContainer className="finance-table-wrap">
|
||||||
|
<Table className="finance-table" size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>APP</TableCell>
|
||||||
|
<TableCell>操作</TableCell>
|
||||||
|
<TableCell>目标用户</TableCell>
|
||||||
|
<TableCell align="right">金币数量</TableCell>
|
||||||
|
<TableCell align="right">充值金额</TableCell>
|
||||||
|
<TableCell>凭证</TableCell>
|
||||||
|
<TableCell>申请人</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">发起申请时间</TableCell>
|
||||||
|
<TableCell>审批人</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">审核时间</TableCell>
|
||||||
|
<TableCell>状态</TableCell>
|
||||||
|
<TableCell align="right">审核</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.appName || item.appCode || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.targetUserId || "-"}</TableCell>
|
||||||
|
<TableCell align="right">{formatAmount(item.coinAmount)}</TableCell>
|
||||||
|
<TableCell align="right">{formatAmount(item.rechargeAmount)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Evidence item={item} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={item.applicantName || "-"} secondary={item.applicantUserId || ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={item.auditorName || "-"} secondary={item.auditorUserId || ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.auditedAtMs)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{item.status === "pending" ? (
|
||||||
|
<span className="finance-row-actions">
|
||||||
|
<Tooltip arrow title="通过">
|
||||||
|
<IconButton aria-label="通过" onClick={() => onAudit({ ...item, decision: "approved" })}>
|
||||||
|
<CheckCircleOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip arrow title="拒绝">
|
||||||
|
<IconButton aria-label="拒绝" onClick={() => onAudit({ ...item, decision: "rejected" })}>
|
||||||
|
<CloseOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center" colSpan={12}>
|
||||||
|
当前无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : null}
|
||||||
|
{total > pageSize ? (
|
||||||
|
<div className="finance-pagination">
|
||||||
|
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
第 {page} 页 / 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Evidence({ item }) {
|
||||||
|
if (!item.credentialImageUrl && !item.credentialText) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="finance-evidence">
|
||||||
|
{item.credentialImageUrl ? (
|
||||||
|
<a href={item.credentialImageUrl} rel="noreferrer" target="_blank">
|
||||||
|
图片
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{item.credentialText ? <span title={item.credentialText}>{item.credentialText}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stacked({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<span className="finance-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<small>{secondary || ""}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const tone = status === "approved" ? "success" : status === "rejected" ? "danger" : "warning";
|
||||||
|
return <span className={`finance-status finance-status--${tone}`}>{statusLabel(status)}</span>;
|
||||||
|
}
|
||||||
67
finance/src/components/FinanceApplicationList.test.jsx
Normal file
67
finance/src/components/FinanceApplicationList.test.jsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { FinanceApplicationList } from "./FinanceApplicationList.jsx";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
applications: {
|
||||||
|
items: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
filters: {
|
||||||
|
appCode: "",
|
||||||
|
keyword: "",
|
||||||
|
operation: "",
|
||||||
|
page: 1,
|
||||||
|
status: ""
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
onAudit: vi.fn(),
|
||||||
|
onFiltersChange: vi.fn(),
|
||||||
|
onReload: vi.fn(),
|
||||||
|
operations: [{ label: "增加币商金币", value: "coin_seller_coin_credit" }],
|
||||||
|
options: { apps: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders finance audit table with explicit applicant and audit time columns", () => {
|
||||||
|
render(
|
||||||
|
<FinanceApplicationList
|
||||||
|
{...baseProps}
|
||||||
|
applications={{
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
applicantName: "张三",
|
||||||
|
auditedAtMs: 1760003600000,
|
||||||
|
auditorName: "王财务",
|
||||||
|
auditorUserId: 99,
|
||||||
|
coinAmount: 200,
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 9,
|
||||||
|
operation: "coin_seller_coin_credit",
|
||||||
|
rechargeAmount: 20,
|
||||||
|
status: "approved",
|
||||||
|
targetUserId: "88"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "发起申请时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审核时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("王财务")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("99")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps empty finance audit table colspan aligned with visible columns", () => {
|
||||||
|
const { container } = render(<FinanceApplicationList {...baseProps} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("tbody td")?.colSpan).toBe(12);
|
||||||
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
|
});
|
||||||
70
finance/src/components/FinanceAuditDialog.jsx
Normal file
70
finance/src/components/FinanceAuditDialog.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import Button from "@mui/material/Button";
|
||||||
|
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 TextField from "@mui/material/TextField";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
|
import { operationLabel } from "../format.js";
|
||||||
|
|
||||||
|
export function FinanceAuditDialog({ application, loading, onClose, onSubmit, open, operations, requireApprovalImage = false, requireRejectRemark = false }) {
|
||||||
|
const [remark, setRemark] = useState("");
|
||||||
|
const [auditImageUrl, setAuditImageUrl] = useState("");
|
||||||
|
const decision = application?.decision || "approved";
|
||||||
|
const needsImage = decision === "approved" && requireApprovalImage;
|
||||||
|
const needsRejectRemark = decision === "rejected" && requireRejectRemark;
|
||||||
|
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setRemark("");
|
||||||
|
setAuditImageUrl("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
onSubmit({ auditImageUrl, decision, remark });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||||
|
<DialogTitle>{decision === "approved" ? "通过申请" : "拒绝申请"}</DialogTitle>
|
||||||
|
<DialogContent className="finance-audit-dialog">
|
||||||
|
<div className="finance-audit-summary">
|
||||||
|
<span>{application?.appName || application?.appCode || "-"}</span>
|
||||||
|
<strong>{operationLabel(application?.operation, operations)}</strong>
|
||||||
|
<span>{application?.targetUserId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
disabled={loading}
|
||||||
|
label="审核备注"
|
||||||
|
minRows={4}
|
||||||
|
multiline
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
/>
|
||||||
|
{needsRejectRemark && !remark.trim() ? <span className="finance-audit-hint">拒绝时必须填写原因</span> : null}
|
||||||
|
{needsImage ? (
|
||||||
|
<UploadField
|
||||||
|
disabled={loading}
|
||||||
|
hideType
|
||||||
|
kind="image"
|
||||||
|
label="打款凭证图片"
|
||||||
|
uploadKind="image"
|
||||||
|
value={auditImageUrl}
|
||||||
|
onChange={setAuditImageUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={loading} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={submitDisabled} variant="contained" onClick={submit}>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
finance/src/components/FinanceMyApplicationList.jsx
Normal file
156
finance/src/components/FinanceMyApplicationList.jsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { APPLICATION_STATUS } from "../constants.js";
|
||||||
|
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
|
||||||
|
|
||||||
|
export function FinanceMyApplicationList({ applications, error, filters, loading, onCreate, onFiltersChange, onReload, operations, options }) {
|
||||||
|
const items = applications.items || [];
|
||||||
|
const page = Number(applications.page || filters.page || 1);
|
||||||
|
const pageSize = Number(applications.pageSize || 50);
|
||||||
|
const total = Number(applications.total || 0);
|
||||||
|
const hasNextPage = page * pageSize < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="finance-panel finance-list">
|
||||||
|
<div className="finance-toolbar finance-toolbar--with-action">
|
||||||
|
<TextField label="APP" select value={filters.appCode} onChange={(event) => onFiltersChange({ appCode: event.target.value })}>
|
||||||
|
<MenuItem value="">全部 APP</MenuItem>
|
||||||
|
{options.apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="操作内容" select value={filters.operation} onChange={(event) => onFiltersChange({ operation: event.target.value })}>
|
||||||
|
<MenuItem value="">全部操作</MenuItem>
|
||||||
|
{operations.map((operation) => (
|
||||||
|
<MenuItem key={operation.value} value={operation.value}>
|
||||||
|
{operation.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="状态" select value={filters.status} onChange={(event) => onFiltersChange({ status: event.target.value })}>
|
||||||
|
<MenuItem value="">全部状态</MenuItem>
|
||||||
|
{APPLICATION_STATUS.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="搜索" value={filters.keyword} onChange={(event) => onFiltersChange({ keyword: event.target.value })} />
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
|
刷新列表
|
||||||
|
</Button>
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={onCreate}>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
|
{!loading || items.length ? (
|
||||||
|
<TableContainer className="finance-table-wrap">
|
||||||
|
<Table className="finance-table" size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>APP</TableCell>
|
||||||
|
<TableCell>操作</TableCell>
|
||||||
|
<TableCell>目标用户</TableCell>
|
||||||
|
<TableCell align="right">金币数量</TableCell>
|
||||||
|
<TableCell align="right">充值金额</TableCell>
|
||||||
|
<TableCell>凭证</TableCell>
|
||||||
|
<TableCell>状态</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">发起申请时间</TableCell>
|
||||||
|
<TableCell>审批人</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">审核时间</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.appName || item.appCode || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.targetUserId || "-"}</TableCell>
|
||||||
|
<TableCell align="right">{formatAmount(item.coinAmount)}</TableCell>
|
||||||
|
<TableCell align="right">{formatAmount(item.rechargeAmount)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Evidence item={item} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={item.auditorName || "-"} secondary={item.auditorUserId || ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.auditedAtMs)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center" colSpan={10}>
|
||||||
|
当前无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : null}
|
||||||
|
{total > pageSize ? (
|
||||||
|
<div className="finance-pagination">
|
||||||
|
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
第 {page} 页 / 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Evidence({ item }) {
|
||||||
|
if (!item.credentialImageUrl && !item.credentialText) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="finance-evidence">
|
||||||
|
{item.credentialImageUrl ? (
|
||||||
|
<a href={item.credentialImageUrl} rel="noreferrer" target="_blank">
|
||||||
|
图片
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{item.credentialText ? <span title={item.credentialText}>{item.credentialText}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stacked({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<span className="finance-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<small>{secondary || ""}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const tone = status === "approved" ? "success" : status === "rejected" ? "danger" : "warning";
|
||||||
|
return <span className={`finance-status finance-status--${tone}`}>{statusLabel(status)}</span>;
|
||||||
|
}
|
||||||
81
finance/src/components/FinanceMyApplicationList.test.jsx
Normal file
81
finance/src/components/FinanceMyApplicationList.test.jsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { FinanceMyApplicationList } from "./FinanceMyApplicationList.jsx";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
applications: {
|
||||||
|
items: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
filters: {
|
||||||
|
appCode: "",
|
||||||
|
keyword: "",
|
||||||
|
operation: "",
|
||||||
|
page: 1,
|
||||||
|
status: ""
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
onCreate: vi.fn(),
|
||||||
|
onFiltersChange: vi.fn(),
|
||||||
|
onReload: vi.fn(),
|
||||||
|
operations: [{ label: "增加用户金币", value: "user_coin_credit" }],
|
||||||
|
options: { apps: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders my finance application list and create action", () => {
|
||||||
|
const onCreate = vi.fn();
|
||||||
|
render(
|
||||||
|
<FinanceMyApplicationList
|
||||||
|
{...baseProps}
|
||||||
|
applications={{
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
auditedAtMs: 1760007200000,
|
||||||
|
auditorName: "财务一",
|
||||||
|
auditorUserId: 21,
|
||||||
|
coinAmount: 100,
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
credentialText: "receipt",
|
||||||
|
id: 7,
|
||||||
|
operation: "user_coin_credit",
|
||||||
|
rechargeAmount: 12.5,
|
||||||
|
status: "approved",
|
||||||
|
targetUserId: "10001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}}
|
||||||
|
onCreate={onCreate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "目标用户" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "金币数量" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "充值金额" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "凭证" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "状态" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "发起申请时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审核时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("lalu")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("增加用户金币")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已通过")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
||||||
|
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps empty my finance application table colspan aligned with visible columns", () => {
|
||||||
|
const { container } = render(<FinanceMyApplicationList {...baseProps} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("tbody td")?.colSpan).toBe(10);
|
||||||
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
|
});
|
||||||
207
finance/src/components/FinanceRechargeDetailList.jsx
Normal file
207
finance/src/components/FinanceRechargeDetailList.jsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import {
|
||||||
|
formatAmount,
|
||||||
|
formatMicroMoney,
|
||||||
|
formatTime,
|
||||||
|
formatUsdMinor,
|
||||||
|
formatPercent,
|
||||||
|
rechargeTypeLabel,
|
||||||
|
} from "../format.js";
|
||||||
|
|
||||||
|
export function FinanceRechargeDetailList({ bills, error, filters, loading, onFiltersChange, onReload, options }) {
|
||||||
|
const items = bills.items || [];
|
||||||
|
const page = Number(bills.page || filters.page || 1);
|
||||||
|
const pageSize = Number(bills.pageSize || 50);
|
||||||
|
const total = Number(bills.total || 0);
|
||||||
|
const hasNextPage = page * pageSize < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="finance-panel finance-list">
|
||||||
|
<div className="finance-toolbar finance-toolbar--recharge-details">
|
||||||
|
<TextField
|
||||||
|
label="APP"
|
||||||
|
select
|
||||||
|
value={filters.appCode}
|
||||||
|
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部 APP</MenuItem>
|
||||||
|
{options.apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TimeRangeFilter
|
||||||
|
label="充值时间 · 中国时区"
|
||||||
|
value={filters.timeRange}
|
||||||
|
onChange={(timeRange) => onFiltersChange({ timeRange })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="订单搜索"
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
|
刷新列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
|
{!loading || items.length ? (
|
||||||
|
<TableContainer className="finance-table-wrap">
|
||||||
|
<Table className="finance-table finance-table--recharge-details" size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>APP</TableCell>
|
||||||
|
<TableCell align="right">充值总额</TableCell>
|
||||||
|
<TableCell>充值来源</TableCell>
|
||||||
|
<TableCell>充值订单号</TableCell>
|
||||||
|
<TableCell>美金换算</TableCell>
|
||||||
|
<TableCell>账单金额</TableCell>
|
||||||
|
<TableCell>用户实付</TableCell>
|
||||||
|
<TableCell>三方税率扣款</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">充值时间</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id || item.transactionId}>
|
||||||
|
<TableCell>{appName(item, options.apps)}</TableCell>
|
||||||
|
<TableCell align="right">{coinText(item.coinAmount)}</TableCell>
|
||||||
|
<TableCell>{rechargeTypeLabel(item.rechargeType)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked
|
||||||
|
primary={item.transactionId || "-"}
|
||||||
|
secondary={secondaryOrderNo(item)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{exchangeText(item)}</TableCell>
|
||||||
|
<TableCell>{billAmountText(item)}</TableCell>
|
||||||
|
<TableCell>{userPaidText(item)}</TableCell>
|
||||||
|
<TableCell>{taxDeductionText(item)}</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">
|
||||||
|
{formatTime(item.createdAtMs)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center" colSpan={9}>
|
||||||
|
当前无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : null}
|
||||||
|
{total > pageSize ? (
|
||||||
|
<div className="finance-pagination">
|
||||||
|
<Button
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
第 {page} 页 / 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
disabled={!hasNextPage || loading}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stacked({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<span className="finance-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<small>{secondary || ""}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appName(item, apps) {
|
||||||
|
return item.appName || apps.find((app) => app.appCode === item.appCode)?.appName || item.appCode || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondaryOrderNo(item) {
|
||||||
|
return [item.commandId, item.externalRef].filter(Boolean).join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function coinText(value) {
|
||||||
|
const text = formatAmount(value);
|
||||||
|
return text === "-" ? "-" : `${text} 金币`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchangeText(item) {
|
||||||
|
if (hasNumber(item.exchangeCoinAmount) || hasNumber(item.exchangeUsdMinorAmount)) {
|
||||||
|
return `${coinText(item.exchangeCoinAmount)} = ${formatUsdMinor(item.exchangeUsdMinorAmount, "USD")}`;
|
||||||
|
}
|
||||||
|
return formatUsdMinor(item.usdMinorAmount, "USD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function billAmountText(item) {
|
||||||
|
if (item.billAmountText) {
|
||||||
|
return item.billAmountText;
|
||||||
|
}
|
||||||
|
const amount = hasNumber(item.billAmountMinor)
|
||||||
|
? item.billAmountMinor
|
||||||
|
: hasNumber(item.providerAmountMinor)
|
||||||
|
? item.providerAmountMinor
|
||||||
|
: item.usdMinorAmount;
|
||||||
|
return formatUsdMinor(amount, item.currencyCode || "USD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPaidText(item) {
|
||||||
|
if (item.userPaidText) {
|
||||||
|
return item.userPaidText;
|
||||||
|
}
|
||||||
|
if (hasNumber(item.userPaidAmountMinor)) {
|
||||||
|
return formatUsdMinor(item.userPaidAmountMinor, item.currencyCode || "USD");
|
||||||
|
}
|
||||||
|
if (hasNumber(item.providerAmountMinor)) {
|
||||||
|
return formatUsdMinor(item.providerAmountMinor, item.currencyCode || "USD");
|
||||||
|
}
|
||||||
|
if (hasNumber(item.userPaidAmountMicro)) {
|
||||||
|
return formatMicroMoney(item.userPaidAmountMicro, item.currencyCode || "USD");
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxDeductionText(item) {
|
||||||
|
const rate = formatPercent(item.taxRate);
|
||||||
|
const amount =
|
||||||
|
item.taxDeductionText ||
|
||||||
|
(hasNumber(item.taxDeductionMinor) ? formatUsdMinor(item.taxDeductionMinor, item.currencyCode || "USD") : "");
|
||||||
|
if (amount && rate) {
|
||||||
|
return `${amount} / ${rate}`;
|
||||||
|
}
|
||||||
|
return amount || rate || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNumber(value) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Number.isFinite(Number(value));
|
||||||
|
}
|
||||||
84
finance/src/components/FinanceRechargeDetailList.test.jsx
Normal file
84
finance/src/components/FinanceRechargeDetailList.test.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
bills: {
|
||||||
|
items: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
filters: {
|
||||||
|
appCode: "",
|
||||||
|
keyword: "",
|
||||||
|
page: 1,
|
||||||
|
timeRange: { endMs: "", startMs: "" },
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
onFiltersChange: vi.fn(),
|
||||||
|
onReload: vi.fn(),
|
||||||
|
options: {
|
||||||
|
apps: [
|
||||||
|
{ appCode: "lalu", appName: "lalu" },
|
||||||
|
{ appCode: "aslan", appName: "Aslan" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders app recharge detail columns and values", () => {
|
||||||
|
render(
|
||||||
|
<FinanceRechargeDetailList
|
||||||
|
{...baseProps}
|
||||||
|
bills={{
|
||||||
|
...baseProps.bills,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "aslan",
|
||||||
|
coinAmount: 90000,
|
||||||
|
commandId: "cmd-1",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
currencyCode: "SAR",
|
||||||
|
exchangeCoinAmount: 90000,
|
||||||
|
exchangeUsdMinorAmount: 999,
|
||||||
|
externalRef: "GPA.1",
|
||||||
|
id: "tx-google",
|
||||||
|
providerAmountMinor: 1299,
|
||||||
|
rechargeType: "google_play_recharge",
|
||||||
|
taxDeductionMinor: 195,
|
||||||
|
taxRate: 0.15,
|
||||||
|
transactionId: "tx-google",
|
||||||
|
usdMinorAmount: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "充值总额" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "美金换算" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Aslan")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("谷歌充值")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("90,000 金币 = USD 9.99")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("SAR 12.99")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /充值时间 · 中国时区/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
||||||
|
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
|
||||||
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
|
});
|
||||||
77
finance/src/components/FinanceShell.jsx
Normal file
77
finance/src/components/FinanceShell.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
||||||
|
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||||
|
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||||
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import LinearProgress from "@mui/material/LinearProgress";
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "APP充值详情" },
|
||||||
|
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
||||||
|
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
||||||
|
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FinanceShell({ activeView, canAudit, canCreate, canViewRechargeDetails, canViewWithdrawals, children, loading, onRefresh, onViewChange, session }) {
|
||||||
|
const visibleViews = views.filter((view) => {
|
||||||
|
if (view.id === "rechargeDetails") {
|
||||||
|
return canViewRechargeDetails;
|
||||||
|
}
|
||||||
|
if (view.id === "applications") {
|
||||||
|
return canAudit;
|
||||||
|
}
|
||||||
|
if (view.id === "withdrawals") {
|
||||||
|
return canViewWithdrawals;
|
||||||
|
}
|
||||||
|
return canCreate;
|
||||||
|
});
|
||||||
|
const activeLabel = visibleViews.find((view) => view.id === activeView)?.label || visibleViews[0]?.label || "财务系统";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="finance-shell">
|
||||||
|
<aside className="finance-sidebar">
|
||||||
|
<div className="finance-brand">
|
||||||
|
<span>HY</span>
|
||||||
|
<strong>财务系统</strong>
|
||||||
|
</div>
|
||||||
|
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||||
|
{visibleViews.map((view) => {
|
||||||
|
const Icon = view.icon;
|
||||||
|
return (
|
||||||
|
<button className={activeView === view.id ? "is-active" : ""} key={view.id} type="button" onClick={() => onViewChange(view.id)}>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
<span>{view.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="finance-user">
|
||||||
|
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
||||||
|
<small>
|
||||||
|
{canViewRechargeDetails
|
||||||
|
? "payment-bill:view"
|
||||||
|
: canAudit
|
||||||
|
? "finance-application:audit"
|
||||||
|
: canViewWithdrawals
|
||||||
|
? "finance-withdrawal:view"
|
||||||
|
: "finance-application:create"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section className="finance-main">
|
||||||
|
<header className="finance-header">
|
||||||
|
<div>
|
||||||
|
<span>HYApp</span>
|
||||||
|
<h1>{activeLabel}</h1>
|
||||||
|
</div>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onRefresh}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
{loading ? <LinearProgress className="finance-progress" /> : null}
|
||||||
|
<div className="finance-content">{children}</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
finance/src/components/FinanceWithdrawalApplicationList.jsx
Normal file
128
finance/src/components/FinanceWithdrawalApplicationList.jsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { formatAmount, formatTime, statusLabel } from "../format.js";
|
||||||
|
|
||||||
|
export function FinanceWithdrawalApplicationList({ applications, canAudit, error, filters, loading, onAudit, onFiltersChange, onReload, options }) {
|
||||||
|
const items = applications.items || [];
|
||||||
|
const page = Number(applications.page || filters.page || 1);
|
||||||
|
const pageSize = Number(applications.pageSize || 50);
|
||||||
|
const total = Number(applications.total || 0);
|
||||||
|
const hasNextPage = page * pageSize < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="finance-panel finance-list">
|
||||||
|
<div className="finance-toolbar">
|
||||||
|
<TextField
|
||||||
|
label="APP"
|
||||||
|
select
|
||||||
|
value={filters.appCode}
|
||||||
|
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">全部 APP</MenuItem>
|
||||||
|
{options.apps.map((app) => (
|
||||||
|
<MenuItem key={app.appCode} value={app.appCode}>
|
||||||
|
{app.appName || app.appCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="搜索" value={filters.keyword} onChange={(event) => onFiltersChange({ keyword: event.target.value })} />
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||||
|
刷新列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="finance-error">{error}</div> : null}
|
||||||
|
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||||
|
{!loading || items.length ? (
|
||||||
|
<TableContainer className="finance-table-wrap">
|
||||||
|
<Table className="finance-table" size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>APP</TableCell>
|
||||||
|
<TableCell>用户ID</TableCell>
|
||||||
|
<TableCell align="right">提现金额</TableCell>
|
||||||
|
<TableCell>提现方式</TableCell>
|
||||||
|
<TableCell>提现地址</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">申请时间</TableCell>
|
||||||
|
<TableCell>审批人</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">审批时间</TableCell>
|
||||||
|
<TableCell align="right">操作</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.appName || item.appCode || "-"}</TableCell>
|
||||||
|
<TableCell>{item.userId || "-"}</TableCell>
|
||||||
|
<TableCell align="right">$ {formatAmount(item.withdrawAmount)}</TableCell>
|
||||||
|
<TableCell>{item.withdrawMethod || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="finance-address" title={item.withdrawAddress}>
|
||||||
|
{item.withdrawAddress || "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stacked primary={item.approverName || "-"} secondary={item.approverUserId || ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="finance-time-cell">{formatTime(item.approvedAtMs)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{item.status === "pending" && canAudit ? (
|
||||||
|
<span className="finance-row-actions">
|
||||||
|
<Button size="small" variant="contained" onClick={() => onAudit({ ...item, decision: "approved", operation: "用户提现", targetUserId: item.userId })}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button color="error" size="small" variant="outlined" onClick={() => onAudit({ ...item, decision: "rejected", operation: "用户提现", targetUserId: item.userId })}>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
statusLabel(item.status)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center" colSpan={9}>
|
||||||
|
当前无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : null}
|
||||||
|
{total > pageSize ? (
|
||||||
|
<div className="finance-pagination">
|
||||||
|
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
第 {page} 页 / 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stacked({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<span className="finance-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<small>{secondary || ""}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
applications: {
|
||||||
|
items: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0
|
||||||
|
},
|
||||||
|
error: "",
|
||||||
|
filters: {
|
||||||
|
appCode: "",
|
||||||
|
keyword: "",
|
||||||
|
page: 1
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
onFiltersChange: vi.fn(),
|
||||||
|
onReload: vi.fn(),
|
||||||
|
options: { apps: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders withdrawal application table with requested finance columns", () => {
|
||||||
|
render(
|
||||||
|
<FinanceWithdrawalApplicationList
|
||||||
|
{...baseProps}
|
||||||
|
applications={{
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
approvedAtMs: 1760007200000,
|
||||||
|
approverName: "财务一",
|
||||||
|
approverUserId: 21,
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 7,
|
||||||
|
userId: "10001",
|
||||||
|
withdrawAddress: "TRX-address",
|
||||||
|
withdrawAmount: 12.5,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "用户ID" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "提现金额" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "提现方式" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "提现地址" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "申请时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "审批时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("lalu")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("$ 12.5")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("USDT-TRC20")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TRX-address")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("财务一")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("21")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps empty withdrawal table colspan aligned with visible columns", () => {
|
||||||
|
const { container } = render(<FinanceWithdrawalApplicationList {...baseProps} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
|
||||||
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
|
});
|
||||||
75
finance/src/constants.js
Normal file
75
finance/src/constants.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
export const FINANCE_PERMISSIONS = {
|
||||||
|
auditApplication: "finance-application:audit",
|
||||||
|
auditWithdrawalApplications: "finance-withdrawal:audit",
|
||||||
|
createApplication: "finance-application:create",
|
||||||
|
viewAppRechargeDetails: "payment-bill:view",
|
||||||
|
viewWithdrawalApplications: "finance-withdrawal:view",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FINANCE_OPERATIONS = [
|
||||||
|
{
|
||||||
|
allowsZeroRechargeAmount: true,
|
||||||
|
label: "增加用户金币",
|
||||||
|
permissionCode: "finance-operation:user-coin-credit",
|
||||||
|
value: "user_coin_credit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowsZeroRechargeAmount: true,
|
||||||
|
label: "减少用户金币",
|
||||||
|
permissionCode: "finance-operation:user-coin-debit",
|
||||||
|
value: "user_coin_debit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "用户钱包扣减",
|
||||||
|
permissionCode: "finance-operation:user-wallet-debit",
|
||||||
|
requiresWalletIdentity: true,
|
||||||
|
value: "user_wallet_debit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "用户钱包增加",
|
||||||
|
permissionCode: "finance-operation:user-wallet-credit",
|
||||||
|
requiresWalletIdentity: true,
|
||||||
|
value: "user_wallet_credit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "增加币商金币",
|
||||||
|
permissionCode: "finance-operation:coin-seller-coin-credit",
|
||||||
|
value: "coin_seller_coin_credit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "减少币商金币",
|
||||||
|
permissionCode: "finance-operation:coin-seller-coin-debit",
|
||||||
|
value: "coin_seller_coin_debit",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WALLET_IDENTITIES = [
|
||||||
|
["host", "Host 钱包"],
|
||||||
|
["agency", "Agency 钱包"],
|
||||||
|
["bd", "BD 钱包"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APPLICATION_STATUS = [
|
||||||
|
["pending", "待审核"],
|
||||||
|
["approved", "已通过"],
|
||||||
|
["rejected", "已拒绝"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_APPLICATION_FORM = {
|
||||||
|
appCode: "",
|
||||||
|
coinAmount: "",
|
||||||
|
credentialImageUrl: "",
|
||||||
|
credentialText: "",
|
||||||
|
operation: "",
|
||||||
|
rechargeAmount: "",
|
||||||
|
targetUserId: "",
|
||||||
|
walletIdentity: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function operationRequiresWalletIdentity(operation) {
|
||||||
|
return FINANCE_OPERATIONS.some((item) => item.value === operation && item.requiresWalletIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationAllowsZeroRechargeAmount(operation) {
|
||||||
|
return FINANCE_OPERATIONS.some((item) => item.value === operation && item.allowsZeroRechargeAmount);
|
||||||
|
}
|
||||||
286
finance/src/format.js
Normal file
286
finance/src/format.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import {
|
||||||
|
APPLICATION_STATUS,
|
||||||
|
FINANCE_OPERATIONS,
|
||||||
|
WALLET_IDENTITIES,
|
||||||
|
operationAllowsZeroRechargeAmount,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export function buildApplicationPayload(form) {
|
||||||
|
const operation = stringValue(form.operation);
|
||||||
|
const payload = {
|
||||||
|
appCode: stringValue(form.appCode),
|
||||||
|
coinAmount: numberValue(form.coinAmount),
|
||||||
|
credentialImageUrl: stringValue(form.credentialImageUrl),
|
||||||
|
credentialText: stringValue(form.credentialText),
|
||||||
|
operation,
|
||||||
|
rechargeAmount: numberValue(form.rechargeAmount),
|
||||||
|
targetUserId: stringValue(form.targetUserId),
|
||||||
|
walletIdentity: requiresWalletIdentity(operation) ? stringValue(form.walletIdentity) : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateApplicationPayload(payload) {
|
||||||
|
if (!payload.appCode) {
|
||||||
|
return "请选择 APP";
|
||||||
|
}
|
||||||
|
if (!payload.operation) {
|
||||||
|
return "请选择操作内容";
|
||||||
|
}
|
||||||
|
if (!payload.targetUserId) {
|
||||||
|
return "请填写目标用户 ID";
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(payload.coinAmount) || payload.coinAmount <= 0) {
|
||||||
|
return "请填写大于 0 的金币数量";
|
||||||
|
}
|
||||||
|
const allowsZeroRechargeAmount = operationAllowsZeroRechargeAmount(payload.operation);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(payload.rechargeAmount) ||
|
||||||
|
payload.rechargeAmount < 0 ||
|
||||||
|
(!allowsZeroRechargeAmount && payload.rechargeAmount <= 0)
|
||||||
|
) {
|
||||||
|
return allowsZeroRechargeAmount ? "请填写不小于 0 的充值金额" : "请填写大于 0 的充值金额";
|
||||||
|
}
|
||||||
|
if (requiresWalletIdentity(payload.operation) && !payload.walletIdentity) {
|
||||||
|
return "请选择钱包身份";
|
||||||
|
}
|
||||||
|
if (!payload.credentialImageUrl && !payload.credentialText) {
|
||||||
|
return "请上传凭证图片或填写凭证文字";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApplication(item = {}) {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
appName: stringValue(item.appName ?? item.app_name),
|
||||||
|
applicantName: stringValue(
|
||||||
|
item.applicantName ?? item.applicant_name ?? item.applicantAccount ?? item.applicant_account,
|
||||||
|
),
|
||||||
|
applicantUserId: item.applicantUserId ?? item.applicant_user_id,
|
||||||
|
auditedAtMs: numberOrNull(item.auditedAtMs ?? item.audited_at_ms),
|
||||||
|
auditorName: stringValue(item.auditorName ?? item.auditor_name ?? item.auditorAccount ?? item.auditor_account),
|
||||||
|
auditorUserId: item.auditorUserId ?? item.auditor_user_id,
|
||||||
|
auditRemark: stringValue(item.auditRemark ?? item.audit_remark),
|
||||||
|
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
|
||||||
|
createdAtMs: numberOrNull(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
credentialImageUrl: stringValue(item.credentialImageUrl ?? item.credential_image_url),
|
||||||
|
credentialText: stringValue(item.credentialText ?? item.credential_text),
|
||||||
|
id: item.id ?? item.applicationId ?? item.application_id,
|
||||||
|
operation: stringValue(item.operation),
|
||||||
|
rechargeAmount: numberValue(item.rechargeAmount ?? item.recharge_amount),
|
||||||
|
status: stringValue(item.status) || "pending",
|
||||||
|
targetUserId: stringValue(item.targetUserId ?? item.target_user_id),
|
||||||
|
walletIdentity: stringValue(item.walletIdentity ?? item.wallet_identity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApplicationPage(data = {}) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
return {
|
||||||
|
items: items.map(normalizeApplication),
|
||||||
|
page: Number(data.page || 1),
|
||||||
|
pageSize: Number(data.pageSize ?? data.page_size ?? items.length),
|
||||||
|
total: Number(data.total ?? items.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWithdrawalApplication(item = {}) {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
appName: stringValue(item.appName ?? item.app_name),
|
||||||
|
approvedAtMs: numberOrNull(item.approvedAtMs ?? item.approved_at_ms),
|
||||||
|
approverName: stringValue(
|
||||||
|
item.approverName ?? item.approver_name ?? item.approverAccount ?? item.approver_account,
|
||||||
|
),
|
||||||
|
approverUserId: item.approverUserId ?? item.approver_user_id,
|
||||||
|
auditImageUrl: stringValue(item.auditImageUrl ?? item.audit_image_url),
|
||||||
|
auditRemark: stringValue(item.auditRemark ?? item.audit_remark),
|
||||||
|
auditTransactionId: stringValue(item.auditTransactionId ?? item.audit_transaction_id),
|
||||||
|
createdAtMs: numberOrNull(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
freezeTransactionId: stringValue(item.freezeTransactionId ?? item.freeze_transaction_id),
|
||||||
|
id: item.id ?? item.withdrawalApplicationId ?? item.withdrawal_application_id,
|
||||||
|
salaryAssetType: stringValue(item.salaryAssetType ?? item.salary_asset_type),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
updatedAtMs: numberOrNull(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
|
withdrawAddress: stringValue(item.withdrawAddress ?? item.withdraw_address),
|
||||||
|
withdrawAmount: numberValue(item.withdrawAmount ?? item.withdraw_amount),
|
||||||
|
withdrawAmountMinor: numberValue(item.withdrawAmountMinor ?? item.withdraw_amount_minor),
|
||||||
|
withdrawMethod: stringValue(item.withdrawMethod ?? item.withdraw_method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWithdrawalApplicationPage(data = {}) {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
return {
|
||||||
|
items: items.map(normalizeWithdrawalApplication),
|
||||||
|
page: Number(data.page || 1),
|
||||||
|
pageSize: Number(data.pageSize ?? data.page_size ?? items.length),
|
||||||
|
total: Number(data.total ?? items.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRechargeBill(item = {}) {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
appName: stringValue(item.appName ?? item.app_name),
|
||||||
|
billAmountMinor: numberOrNull(item.billAmountMinor ?? item.bill_amount_minor),
|
||||||
|
billAmountText: stringValue(
|
||||||
|
item.billAmountText ?? item.bill_amount_text ?? item.billAmount ?? item.bill_amount,
|
||||||
|
),
|
||||||
|
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
|
||||||
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
|
createdAtMs: numberOrNull(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
currencyCode: stringValue(item.currencyCode ?? item.currency_code) || "USD",
|
||||||
|
exchangeCoinAmount: numberValue(item.exchangeCoinAmount ?? item.exchange_coin_amount),
|
||||||
|
exchangeUsdMinorAmount: numberValue(item.exchangeUsdMinorAmount ?? item.exchange_usd_minor_amount),
|
||||||
|
externalRef: stringValue(item.externalRef ?? item.external_ref),
|
||||||
|
id: stringValue(item.id ?? item.transactionId ?? item.transaction_id),
|
||||||
|
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
|
||||||
|
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
taxDeductionMinor: numberOrNull(
|
||||||
|
item.taxDeductionMinor ??
|
||||||
|
item.tax_deduction_minor ??
|
||||||
|
item.thirdPartyTaxDeductionMinor ??
|
||||||
|
item.third_party_tax_deduction_minor,
|
||||||
|
),
|
||||||
|
taxDeductionText: stringValue(
|
||||||
|
item.taxDeductionText ??
|
||||||
|
item.tax_deduction_text ??
|
||||||
|
item.thirdPartyTaxDeduction ??
|
||||||
|
item.third_party_tax_deduction,
|
||||||
|
),
|
||||||
|
taxRate: numberOrNull(item.taxRate ?? item.tax_rate ?? item.thirdPartyTaxRate ?? item.third_party_tax_rate),
|
||||||
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
|
usdMinorAmount: numberValue(item.usdMinorAmount ?? item.usd_minor_amount),
|
||||||
|
userPaidAmountMicro: numberOrNull(
|
||||||
|
item.userPaidAmountMicro ?? item.user_paid_amount_micro ?? item.paidAmountMicro ?? item.paid_amount_micro,
|
||||||
|
),
|
||||||
|
userPaidAmountMinor: numberOrNull(
|
||||||
|
item.userPaidAmountMinor ??
|
||||||
|
item.user_paid_amount_minor ??
|
||||||
|
item.paidAmountMinor ??
|
||||||
|
item.paid_amount_minor ??
|
||||||
|
item.providerAmountMinor ??
|
||||||
|
item.provider_amount_minor,
|
||||||
|
),
|
||||||
|
userPaidText: stringValue(
|
||||||
|
item.userPaidText ??
|
||||||
|
item.user_paid_text ??
|
||||||
|
item.userPaidAmount ??
|
||||||
|
item.user_paid_amount ??
|
||||||
|
item.paidAmount ??
|
||||||
|
item.paid_amount,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRechargeBillPage(data = {}) {
|
||||||
|
const rawItems = Array.isArray(data.items) ? data.items : Array.isArray(data.bills) ? data.bills : [];
|
||||||
|
return {
|
||||||
|
items: rawItems.map(normalizeRechargeBill),
|
||||||
|
page: Number(data.page || 1),
|
||||||
|
pageSize: Number(data.pageSize ?? data.page_size ?? rawItems.length),
|
||||||
|
total: Number(data.total ?? rawItems.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationLabel(value, operations = FINANCE_OPERATIONS) {
|
||||||
|
return operations.find((item) => item.value === value)?.label || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rechargeTypeLabel(value) {
|
||||||
|
if (value === "coin_seller_transfer") {
|
||||||
|
return "币商充值";
|
||||||
|
}
|
||||||
|
if (value === "google_play_recharge") {
|
||||||
|
return "谷歌充值";
|
||||||
|
}
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(value) {
|
||||||
|
return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function walletIdentityLabel(value) {
|
||||||
|
return WALLET_IDENTITIES.find(([identity]) => identity === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return number.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(ms) {
|
||||||
|
const value = Number(ms);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsdMinor(value, currency = "USD") {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${currency || "USD"} ${(number / 100).toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMicroMoney(value, currency = "USD") {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${currency || "USD"} ${(number / 1_000_000).toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const percent = number > 1 ? number : number * 100;
|
||||||
|
return `${percent.toLocaleString("zh-CN", { maximumFractionDigits: 4 })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiresWalletIdentity(operation) {
|
||||||
|
return FINANCE_OPERATIONS.some((item) => item.value === operation && item.requiresWalletIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value) {
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value) {
|
||||||
|
const nextValue = Number(value);
|
||||||
|
return Number.isFinite(nextValue) ? nextValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
208
finance/src/format.test.js
Normal file
208
finance/src/format.test.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
buildApplicationPayload,
|
||||||
|
normalizeApplicationPage,
|
||||||
|
normalizeRechargeBillPage,
|
||||||
|
normalizeWithdrawalApplicationPage,
|
||||||
|
operationLabel,
|
||||||
|
rechargeTypeLabel,
|
||||||
|
statusLabel,
|
||||||
|
validateApplicationPayload,
|
||||||
|
walletIdentityLabel,
|
||||||
|
} from "./format.js";
|
||||||
|
|
||||||
|
test("builds and validates finance application payload", () => {
|
||||||
|
const payload = buildApplicationPayload({
|
||||||
|
appCode: " lalu ",
|
||||||
|
coinAmount: "3000",
|
||||||
|
credentialImageUrl: "",
|
||||||
|
credentialText: "bank receipt",
|
||||||
|
operation: "user_wallet_credit",
|
||||||
|
rechargeAmount: "15.5",
|
||||||
|
targetUserId: " 10001 ",
|
||||||
|
walletIdentity: "agency",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
appCode: "lalu",
|
||||||
|
coinAmount: 3000,
|
||||||
|
credentialImageUrl: "",
|
||||||
|
credentialText: "bank receipt",
|
||||||
|
operation: "user_wallet_credit",
|
||||||
|
rechargeAmount: 15.5,
|
||||||
|
targetUserId: "10001",
|
||||||
|
walletIdentity: "agency",
|
||||||
|
});
|
||||||
|
expect(validateApplicationPayload(payload)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires wallet identity and credential for wallet operations", () => {
|
||||||
|
const payload = buildApplicationPayload({
|
||||||
|
appCode: "lalu",
|
||||||
|
coinAmount: "100",
|
||||||
|
operation: "user_wallet_debit",
|
||||||
|
rechargeAmount: "1",
|
||||||
|
targetUserId: "10001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateApplicationPayload(payload)).toBe("请选择钱包身份");
|
||||||
|
expect(validateApplicationPayload({ ...payload, walletIdentity: "host" })).toBe("请上传凭证图片或填写凭证文字");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows zero recharge amount only for user coin operations", () => {
|
||||||
|
const payload = buildApplicationPayload({
|
||||||
|
appCode: "lalu",
|
||||||
|
coinAmount: "1111",
|
||||||
|
credentialText: "测试",
|
||||||
|
operation: "user_coin_credit",
|
||||||
|
rechargeAmount: "0",
|
||||||
|
targetUserId: "163185",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateApplicationPayload(payload)).toBe("");
|
||||||
|
expect(validateApplicationPayload({ ...payload, operation: "user_wallet_credit", walletIdentity: "agency" })).toBe(
|
||||||
|
"请填写大于 0 的充值金额",
|
||||||
|
);
|
||||||
|
expect(validateApplicationPayload({ ...payload, operation: "coin_seller_coin_credit" })).toBe(
|
||||||
|
"请填写大于 0 的充值金额",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes finance application list rows", () => {
|
||||||
|
const page = normalizeApplicationPage({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
applicant_name: "张三",
|
||||||
|
audited_at_ms: 1760003600000,
|
||||||
|
auditor_name: "李四",
|
||||||
|
auditor_user_id: 99,
|
||||||
|
coin_amount: 200,
|
||||||
|
created_at_ms: 1760000000000,
|
||||||
|
id: 9,
|
||||||
|
operation: "coin_seller_coin_credit",
|
||||||
|
recharge_amount: 20,
|
||||||
|
status: "pending",
|
||||||
|
target_user_id: 88,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.items[0]).toMatchObject({
|
||||||
|
appCode: "lalu",
|
||||||
|
applicantName: "张三",
|
||||||
|
auditedAtMs: 1760003600000,
|
||||||
|
auditorName: "李四",
|
||||||
|
auditorUserId: 99,
|
||||||
|
coinAmount: 200,
|
||||||
|
operation: "coin_seller_coin_credit",
|
||||||
|
targetUserId: "88",
|
||||||
|
});
|
||||||
|
expect(page.pageSize).toBe(50);
|
||||||
|
expect(operationLabel("coin_seller_coin_credit")).toBe("增加币商金币");
|
||||||
|
expect(statusLabel("pending")).toBe("待审核");
|
||||||
|
expect(walletIdentityLabel("bd")).toBe("BD 钱包");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes finance withdrawal application list rows", () => {
|
||||||
|
const page = normalizeWithdrawalApplicationPage({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
approved_at_ms: 1760007200000,
|
||||||
|
approver_name: "财务一",
|
||||||
|
approver_user_id: 21,
|
||||||
|
audit_image_url: "https://cdn.example.com/pay.png",
|
||||||
|
audit_remark: "已打款",
|
||||||
|
audit_transaction_id: "tx-audit",
|
||||||
|
created_at_ms: 1760000000000,
|
||||||
|
freeze_transaction_id: "tx-freeze",
|
||||||
|
id: 7,
|
||||||
|
salary_asset_type: "HOST_SALARY_USD",
|
||||||
|
status: "approved",
|
||||||
|
user_id: 10001,
|
||||||
|
withdraw_address: "TRX-address",
|
||||||
|
withdraw_amount: "12.5",
|
||||||
|
withdraw_amount_minor: 1250,
|
||||||
|
withdraw_method: "USDT-TRC20",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 2,
|
||||||
|
page_size: 50,
|
||||||
|
total: 51,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page).toMatchObject({
|
||||||
|
page: 2,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 51,
|
||||||
|
});
|
||||||
|
expect(page.items[0]).toMatchObject({
|
||||||
|
appCode: "lalu",
|
||||||
|
approvedAtMs: 1760007200000,
|
||||||
|
approverName: "财务一",
|
||||||
|
approverUserId: 21,
|
||||||
|
auditImageUrl: "https://cdn.example.com/pay.png",
|
||||||
|
auditRemark: "已打款",
|
||||||
|
auditTransactionId: "tx-audit",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
freezeTransactionId: "tx-freeze",
|
||||||
|
id: 7,
|
||||||
|
salaryAssetType: "HOST_SALARY_USD",
|
||||||
|
userId: "10001",
|
||||||
|
withdrawAddress: "TRX-address",
|
||||||
|
withdrawAmount: 12.5,
|
||||||
|
withdrawAmountMinor: 1250,
|
||||||
|
withdrawMethod: "USDT-TRC20",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes app recharge detail list rows with optional payment fields", () => {
|
||||||
|
const page = normalizeRechargeBillPage({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
app_code: "lalu",
|
||||||
|
bill_amount_minor: 1299,
|
||||||
|
coin_amount: 90000,
|
||||||
|
command_id: "cmd-1",
|
||||||
|
created_at_ms: 1760000000000,
|
||||||
|
currency_code: "USD",
|
||||||
|
exchange_coin_amount: 90000,
|
||||||
|
exchange_usd_minor_amount: 999,
|
||||||
|
external_ref: "GPA.1",
|
||||||
|
paid_amount_micro: 12990000,
|
||||||
|
provider_amount_minor: 1299,
|
||||||
|
recharge_type: "google_play_recharge",
|
||||||
|
status: "succeeded",
|
||||||
|
tax_rate: "0.15",
|
||||||
|
third_party_tax_deduction_minor: 195,
|
||||||
|
transaction_id: "tx-google",
|
||||||
|
usd_minor_amount: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.items[0]).toMatchObject({
|
||||||
|
appCode: "lalu",
|
||||||
|
billAmountMinor: 1299,
|
||||||
|
coinAmount: 90000,
|
||||||
|
commandId: "cmd-1",
|
||||||
|
exchangeCoinAmount: 90000,
|
||||||
|
exchangeUsdMinorAmount: 999,
|
||||||
|
externalRef: "GPA.1",
|
||||||
|
providerAmountMinor: 1299,
|
||||||
|
rechargeType: "google_play_recharge",
|
||||||
|
taxDeductionMinor: 195,
|
||||||
|
taxRate: 0.15,
|
||||||
|
transactionId: "tx-google",
|
||||||
|
userPaidAmountMicro: 12990000,
|
||||||
|
usdMinorAmount: 999,
|
||||||
|
});
|
||||||
|
expect(rechargeTypeLabel("google_play_recharge")).toBe("谷歌充值");
|
||||||
|
});
|
||||||
21
finance/src/main.jsx
Normal file
21
finance/src/main.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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 { FinanceApp } from "./FinanceApp.jsx";
|
||||||
|
import "@/styles/tokens.css";
|
||||||
|
import "@/styles/shared-ui.css";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("finance-root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<ToastProvider>
|
||||||
|
<CssBaseline />
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
438
finance/src/styles/index.css
Normal file
438
finance/src/styles/index.css
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
:root {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-page);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#finance-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-shell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-sidebar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-brand span {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--active-contrast);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-brand strong,
|
||||||
|
.finance-header h1 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-nav button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-nav button:hover {
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-nav button.is-active {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-user {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-user small,
|
||||||
|
.finance-header span,
|
||||||
|
.finance-stack small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 72px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-header h1 {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-progress {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-content {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-evidence-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 360px) minmax(280px, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-toolbar--with-action {
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr)) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-toolbar--recharge-details {
|
||||||
|
grid-template-columns: minmax(170px, 1fr) minmax(280px, 1.35fr) minmax(180px, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-error {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table-wrap {
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table {
|
||||||
|
min-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table--recharge-details {
|
||||||
|
min-width: 1480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table th {
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table td,
|
||||||
|
.finance-table th {
|
||||||
|
border-bottom-color: var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-time-cell {
|
||||||
|
min-width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-stack,
|
||||||
|
.finance-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-address {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: bottom;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-evidence span {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 64px;
|
||||||
|
height: var(--admin-tag-height);
|
||||||
|
border-radius: var(--admin-tag-radius);
|
||||||
|
font-size: var(--admin-tag-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-status--success {
|
||||||
|
background: color-mix(in srgb, var(--success) 10%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-status--warning {
|
||||||
|
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-status--danger {
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-audit-dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-top: var(--space-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-audit-hint {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-create-dialog {
|
||||||
|
padding: var(--space-2) var(--space-5) var(--space-5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-create-dialog-paper {
|
||||||
|
width: min(920px, calc(100vw - 64px));
|
||||||
|
max-width: min(920px, calc(100vw - 64px)) !important;
|
||||||
|
border-radius: var(--radius-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-create-dialog-paper .MuiDialogTitle-root {
|
||||||
|
padding: var(--space-5) var(--space-5) var(--space-2);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog .finance-form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog .finance-evidence-grid {
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog .finance-credential-preview {
|
||||||
|
height: 172px;
|
||||||
|
min-height: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog .MuiInputBase-root.MuiInputBase-multiline {
|
||||||
|
min-height: 232px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-audit-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-state-screen {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg-page);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-state-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: min(420px, 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-state-panel h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-skeleton {
|
||||||
|
min-height: 360px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: linear-gradient(90deg, var(--bg-card-strong), var(--bg-page), var(--bg-card-strong));
|
||||||
|
background-size: 240% 100%;
|
||||||
|
animation: finance-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finance-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 120% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -120% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.finance-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-sidebar {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-nav {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form-grid,
|
||||||
|
.finance-evidence-grid,
|
||||||
|
.finance-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-create-dialog-paper {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
max-width: calc(100vw - 32px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-form--dialog .finance-form-grid,
|
||||||
|
.finance-form--dialog .finance-evidence-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,11 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="refresh" content="0; url=/finance/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HYApp 财务系统</title>
|
<title>HYApp 财务系统跳转</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="money-root"></div>
|
<a href="/finance/">进入财务系统</a>
|
||||||
<script type="module" src="/money/src/main.jsx"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,319 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { fetchMoneySession } from "./api.js";
|
|
||||||
import { CountryChannelPanel } from "./components/CountryChannelPanel.jsx";
|
|
||||||
import { MoneyHeader } from "./components/MoneyHeader.jsx";
|
|
||||||
import { MoneyOverview } from "./components/MoneyOverview.jsx";
|
|
||||||
import { MONEY_VIEWS, MoneySidebar } from "./components/MoneySidebar.jsx";
|
|
||||||
import { OperatorPerformancePanel } from "./components/OperatorPerformancePanel.jsx";
|
|
||||||
import { PaymentLinkPanel } from "./components/PaymentLinkPanel.jsx";
|
|
||||||
import { PerformanceTable } from "./components/PerformanceTable.jsx";
|
|
||||||
import {
|
|
||||||
filterMoneyModules,
|
|
||||||
moneyAppOptions,
|
|
||||||
moneyCountries,
|
|
||||||
moneyOperators,
|
|
||||||
summarizeMoneyModules
|
|
||||||
} from "./data.js";
|
|
||||||
import {
|
|
||||||
appOptionsFromMasterData,
|
|
||||||
countryOptionsFromMasterData,
|
|
||||||
fetchMoneyMasterData,
|
|
||||||
regionOptionsFromMasterData
|
|
||||||
} from "./masterDataApi.js";
|
|
||||||
|
|
||||||
const emptyMasterData = { apps: [], countries: [], error: "", loading: false, modules: [], performanceSummary: {}, regions: [], scopes: [] };
|
|
||||||
|
|
||||||
export function MoneyApp() {
|
|
||||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
|
||||||
const [masterDataState, setMasterDataState] = useState(emptyMasterData);
|
|
||||||
const [activeView, setActiveView] = useState("overview");
|
|
||||||
const [mode, setMode] = useState("finance");
|
|
||||||
const [activeOperatorId, setActiveOperatorId] = useState("sarah-ali");
|
|
||||||
const [appCode, setAppCode] = useState("");
|
|
||||||
const [countryCode, setCountryCode] = useState("");
|
|
||||||
const [regionId, setRegionId] = useState("");
|
|
||||||
const [operatorKeyword, setOperatorKeyword] = useState("");
|
|
||||||
const [links, setLinks] = useState([]);
|
|
||||||
const [toast, setToast] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
fetchMoneySession()
|
|
||||||
.then((session) => {
|
|
||||||
if (mounted) {
|
|
||||||
setSessionState({ error: "", loading: false, session });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setSessionState({ error: err.message || "会话校验失败", loading: false, session: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionState.session?.canViewMoney) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let mounted = true;
|
|
||||||
setMasterDataState((current) => ({ ...current, error: "", loading: true }));
|
|
||||||
fetchMoneyMasterData()
|
|
||||||
.then((data) => {
|
|
||||||
if (mounted) {
|
|
||||||
setMasterDataState({ ...data, loading: false });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setMasterDataState({ ...emptyMasterData, error: err.message || "主数据加载失败", loading: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [sessionState.session?.canViewMoney]);
|
|
||||||
|
|
||||||
const appOptions = useMemo(() => appOptionsFromMasterData(masterDataState.apps, moneyAppOptions), [masterDataState.apps]);
|
|
||||||
const countryOptions = useMemo(
|
|
||||||
() => countryOptionsFromMasterData(masterDataState.countries, moneyCountries),
|
|
||||||
[masterDataState.countries]
|
|
||||||
);
|
|
||||||
const regionOptions = useMemo(() => regionOptionsFromMasterData(masterDataState.regions), [masterDataState.regions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (appCode && !hasOption(appOptions, appCode)) {
|
|
||||||
setAppCode("");
|
|
||||||
}
|
|
||||||
}, [appCode, appOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (countryCode && !hasOption(countryOptions, countryCode)) {
|
|
||||||
setCountryCode("");
|
|
||||||
}
|
|
||||||
}, [countryCode, countryOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (regionId && !hasOption(regionOptions, regionId)) {
|
|
||||||
setRegionId("");
|
|
||||||
}
|
|
||||||
}, [regionId, regionOptions]);
|
|
||||||
|
|
||||||
const filteredModules = useMemo(
|
|
||||||
() =>
|
|
||||||
filterMoneyModules({
|
|
||||||
activeOperatorId,
|
|
||||||
appCode,
|
|
||||||
countryCode,
|
|
||||||
mode,
|
|
||||||
modules: masterDataState.modules,
|
|
||||||
operatorKeyword: mode === "finance" ? operatorKeyword : "",
|
|
||||||
regionId,
|
|
||||||
regions: masterDataState.regions
|
|
||||||
}),
|
|
||||||
[activeOperatorId, appCode, countryCode, masterDataState.modules, masterDataState.regions, mode, operatorKeyword, regionId]
|
|
||||||
);
|
|
||||||
const summary = useMemo(() => summarizeMoneyModules(filteredModules), [filteredModules]);
|
|
||||||
const visibleModuleIds = useMemo(() => new Set(filteredModules.map((module) => module.id)), [filteredModules]);
|
|
||||||
const visibleLinks = useMemo(() => links.filter((link) => visibleModuleIds.has(link.moduleId)), [links, visibleModuleIds]);
|
|
||||||
const operators = useMemo(() => operatorsFromModules(masterDataState.modules), [masterDataState.modules]);
|
|
||||||
const activeOperator = operators.find((operator) => operator.id === activeOperatorId) || operators[0] || moneyOperators[0];
|
|
||||||
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setAppCode("");
|
|
||||||
setCountryCode("");
|
|
||||||
setRegionId("");
|
|
||||||
setOperatorKeyword("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const showToast = (message) => {
|
|
||||||
setToast(message);
|
|
||||||
window.setTimeout(() => setToast(""), 1600);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyLink = async (value) => {
|
|
||||||
await copyText(value);
|
|
||||||
showToast("链接已复制");
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPaymentLink = (link) => {
|
|
||||||
setLinks((current) => [link, ...current.filter((item) => (item.orderId || item.id) !== (link.orderId || link.id))]);
|
|
||||||
showToast("支付链接已创建");
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportReport = () => {
|
|
||||||
downloadTextFile(createCsv(filteredModules), `hyapp-money-${Date.now()}.csv`);
|
|
||||||
showToast("财务报表已导出");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionState.loading) {
|
|
||||||
return <StateScreen title="正在进入财务系统" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionState.error) {
|
|
||||||
return <StateScreen title={sessionState.error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionState.session?.canViewMoney) {
|
|
||||||
return <StateScreen title="无 money:view" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="money-shell">
|
|
||||||
<MoneySidebar
|
|
||||||
activeOperator={activeOperator}
|
|
||||||
activeView={activeView}
|
|
||||||
mode={mode}
|
|
||||||
onModeChange={setMode}
|
|
||||||
onViewChange={setActiveView}
|
|
||||||
session={sessionState.session}
|
|
||||||
summary={summary}
|
|
||||||
/>
|
|
||||||
<section className="money-main">
|
|
||||||
<MoneyHeader
|
|
||||||
activeOperatorId={activeOperatorId}
|
|
||||||
appCode={appCode}
|
|
||||||
appOptions={appOptions}
|
|
||||||
countryCode={countryCode}
|
|
||||||
countryOptions={countryOptions}
|
|
||||||
masterDataError={masterDataState.error}
|
|
||||||
masterDataLoading={masterDataState.loading}
|
|
||||||
mode={mode}
|
|
||||||
onActiveOperatorChange={setActiveOperatorId}
|
|
||||||
onAppChange={setAppCode}
|
|
||||||
onCountryChange={setCountryCode}
|
|
||||||
onExport={exportReport}
|
|
||||||
onOperatorKeywordChange={setOperatorKeyword}
|
|
||||||
onRegionChange={setRegionId}
|
|
||||||
onReset={resetFilters}
|
|
||||||
operatorKeyword={operatorKeyword}
|
|
||||||
operators={operators}
|
|
||||||
regionId={regionId}
|
|
||||||
regionOptions={regionOptions}
|
|
||||||
view={view}
|
|
||||||
/>
|
|
||||||
<div className="money-content">
|
|
||||||
{renderContent(activeView, {
|
|
||||||
copyLink,
|
|
||||||
countries: masterDataState.countries,
|
|
||||||
filteredModules,
|
|
||||||
onPaymentLinkCreated: addPaymentLink,
|
|
||||||
regions: masterDataState.regions,
|
|
||||||
summary,
|
|
||||||
visibleLinks
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{toast ? <div className="money-toast">{toast}</div> : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContent(activeView, { copyLink, countries, filteredModules, onPaymentLinkCreated, regions, summary, visibleLinks }) {
|
|
||||||
if (activeView === "modules") {
|
|
||||||
return <PerformanceTable meta={`${filteredModules.length} 个区域`} modules={filteredModules} summary={summary} title="区域绩效" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeView === "operators") {
|
|
||||||
return <OperatorPerformancePanel modules={filteredModules} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeView === "payments") {
|
|
||||||
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreated={onPaymentLinkCreated} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeView === "countries") {
|
|
||||||
return <CountryChannelPanel countries={countries} links={visibleLinks} modules={filteredModules} regions={regions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasOption(options, value) {
|
|
||||||
return options.some(([optionValue]) => String(optionValue) === String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function StateScreen({ detail = "", title }) {
|
|
||||||
return (
|
|
||||||
<main className="money-shell money-shell--state">
|
|
||||||
<section className="money-state">
|
|
||||||
<h1>{title}</h1>
|
|
||||||
{detail ? <p>{detail}</p> : null}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCsv(modules) {
|
|
||||||
const header = ["区域", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
|
|
||||||
const rows = modules.map((module) => [
|
|
||||||
module.moduleName,
|
|
||||||
module.operatorName,
|
|
||||||
module.countryName,
|
|
||||||
module.rechargeUsd,
|
|
||||||
module.revenueUsd,
|
|
||||||
module.salaryUsd,
|
|
||||||
module.newUsers,
|
|
||||||
module.dau,
|
|
||||||
module.dayTargetUsd,
|
|
||||||
module.monthTargetUsd
|
|
||||||
]);
|
|
||||||
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatorsFromModules(modules) {
|
|
||||||
const seen = new Set();
|
|
||||||
return modules
|
|
||||||
.map((module) => ({
|
|
||||||
department: module.department || "",
|
|
||||||
id: module.operatorId,
|
|
||||||
label: module.operatorName || module.operatorId
|
|
||||||
}))
|
|
||||||
.filter((operator) => {
|
|
||||||
if (!operator.id || seen.has(operator.id)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.add(operator.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function csvCell(value) {
|
|
||||||
const text = String(value ?? "");
|
|
||||||
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadTextFile(content, filename) {
|
|
||||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyText(value) {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// 本地 HTTP 调试时浏览器可能拒绝 Clipboard API,退回同步复制以保证财务能拿到刚生成的收款链接。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const input = document.createElement("textarea");
|
|
||||||
input.value = value;
|
|
||||||
input.setAttribute("readonly", "");
|
|
||||||
input.style.position = "fixed";
|
|
||||||
input.style.opacity = "0";
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
input.remove();
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
|
|
||||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
|
||||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
|
||||||
const APP_CODE_HEADER = "X-App-Code";
|
|
||||||
const MONEY_PERMISSION = "money:view";
|
|
||||||
const DEFAULT_APP_CODE = "lalu";
|
|
||||||
|
|
||||||
export async function fetchMoneySession() {
|
|
||||||
const session = await adminApiGet("/v1/auth/me");
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
canViewMoney: Array.isArray(session.permissions) && session.permissions.includes(MONEY_PERMISSION)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adminApiGet(path, options = {}) {
|
|
||||||
return adminApiRequest(path, { ...options, method: "GET" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function adminApiRequest(path, options = {}) {
|
|
||||||
const response = await fetch(adminApiUrl(path, options.query), {
|
|
||||||
body: requestBody(options.body),
|
|
||||||
credentials: "include",
|
|
||||||
headers: requestHeaders(options),
|
|
||||||
method: options.method || (options.body === undefined ? "GET" : "POST")
|
|
||||||
});
|
|
||||||
const payload = await readJSON(response);
|
|
||||||
if (response.status === 401 || Number(payload?.code) === 40100) {
|
|
||||||
redirectToLogin();
|
|
||||||
throw new Error("登录已失效");
|
|
||||||
}
|
|
||||||
if (!response.ok || isApiFailure(payload)) {
|
|
||||||
throw new Error(payload.message || response.statusText || "会话校验失败");
|
|
||||||
}
|
|
||||||
return Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestHeaders(options = {}) {
|
|
||||||
const headers = { ...(options.headers || {}) };
|
|
||||||
const token = window.localStorage.getItem(TOKEN_KEY);
|
|
||||||
const appCode = window.localStorage.getItem(APP_CODE_KEY) || DEFAULT_APP_CODE;
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
if (appCode && !headers[APP_CODE_HEADER]) {
|
|
||||||
headers[APP_CODE_HEADER] = appCode;
|
|
||||||
}
|
|
||||||
if (options.body !== undefined && !(options.body instanceof FormData) && !headers["Content-Type"]) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestBody(body) {
|
|
||||||
if (body === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return body instanceof FormData ? body : JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminApiUrl(path, query) {
|
|
||||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
|
||||||
Object.entries(query || {}).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
url.searchParams.set(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJSON(response) {
|
|
||||||
const text = await response.text();
|
|
||||||
if (!text) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return { code: response.ok ? 0 : response.status, message: text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isApiFailure(payload) {
|
|
||||||
const code = payload?.code;
|
|
||||||
const errorCode = payload?.errorCode;
|
|
||||||
return (
|
|
||||||
(typeof code === "number" && code !== 0) ||
|
|
||||||
(typeof code === "string" && code !== "" && code !== "0" && code !== "OK") ||
|
|
||||||
(typeof errorCode === "number" && errorCode !== 0) ||
|
|
||||||
(typeof errorCode === "string" && errorCode !== "" && errorCode !== "0" && errorCode !== "OK")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectToLogin() {
|
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
|
||||||
if (window.location.pathname !== "/login") {
|
|
||||||
window.location.replace("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { BarChart, LineChart } from "echarts/charts";
|
|
||||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
|
||||||
import * as echarts from "echarts/core";
|
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
|
||||||
|
|
||||||
echarts.use([BarChart, GridComponent, LegendComponent, LineChart, TooltipComponent, CanvasRenderer]);
|
|
||||||
|
|
||||||
export function EChart({ className = "", option }) {
|
|
||||||
const elementRef = useRef(null);
|
|
||||||
const chartRef = useRef(null);
|
|
||||||
const optionRef = useRef(option);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
optionRef.current = option;
|
|
||||||
chartRef.current?.setOption(option, true);
|
|
||||||
}, [option]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const element = elementRef.current;
|
|
||||||
if (!element) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chart = echarts.init(element, null, { renderer: "canvas" });
|
|
||||||
chartRef.current = chart;
|
|
||||||
chart.setOption(optionRef.current, true);
|
|
||||||
|
|
||||||
const resize = () => chart.resize();
|
|
||||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
|
|
||||||
observer?.observe(element);
|
|
||||||
window.addEventListener("resize", resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer?.disconnect();
|
|
||||||
window.removeEventListener("resize", resize);
|
|
||||||
chart.dispose();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <div className={["money-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { availableProvidersForCountry, moneyCountries, summarizeMoneyModules } from "../data.js";
|
|
||||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export function CountryChannelPanel({ countries = [], links, modules, regions = [] }) {
|
|
||||||
const rows = countryRows(countries, modules, links, regions)
|
|
||||||
.map((country) => {
|
|
||||||
const countryModules = modules.filter((module) => module.countryCode === country.code);
|
|
||||||
const countryLinks = links.filter((link) => link.countryCode === country.code);
|
|
||||||
const summary = summarizeMoneyModules(countryModules);
|
|
||||||
return {
|
|
||||||
...country,
|
|
||||||
linkCount: countryLinks.length,
|
|
||||||
modules: countryModules,
|
|
||||||
paidLinkCount: countryLinks.filter((link) => link.status === "paid").length,
|
|
||||||
providers: availableProvidersForCountry(country.code),
|
|
||||||
summary
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((country) => country.modules.length > 0 || country.linkCount > 0)
|
|
||||||
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="money-country-grid">
|
|
||||||
{rows.map((country) => (
|
|
||||||
<article className="money-panel money-country-card" key={country.code}>
|
|
||||||
<div className="money-country-head">
|
|
||||||
<div>
|
|
||||||
<span>{country.region}</span>
|
|
||||||
<h2>{country.label}</h2>
|
|
||||||
</div>
|
|
||||||
<strong>{country.currency}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-country-metrics">
|
|
||||||
<Metric label="充值" value={formatUsd(country.summary.rechargeUsd)} tone="success" />
|
|
||||||
<Metric label="链接转化" value={formatPercent(ratio(country.paidLinkCount, country.linkCount))} tone="primary" />
|
|
||||||
<Metric label="区域" value={formatNumber(country.modules.length)} tone="neutral" />
|
|
||||||
</div>
|
|
||||||
<div className="money-provider-list">
|
|
||||||
{country.providers.map((provider) => (
|
|
||||||
<span key={provider.code}>{provider.label}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="money-country-foot">
|
|
||||||
<span>{formatNumber(country.summary.newUsers)} 新增</span>
|
|
||||||
<span>{formatNumber(country.summary.dau)} DAU</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function countryRows(countries, modules, links, regions) {
|
|
||||||
const fallbackCountries = new Map(moneyCountries.map((country) => [country.code, country]));
|
|
||||||
const realCountries = new Map(countries.map((country) => [country.countryCode, country]));
|
|
||||||
const codes = new Set([
|
|
||||||
...moneyCountries.map((country) => country.code),
|
|
||||||
...modules.map((module) => module.countryCode),
|
|
||||||
...links.map((link) => link.countryCode)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [...codes]
|
|
||||||
.filter((code) => code && code !== "ALL")
|
|
||||||
.map((code) => {
|
|
||||||
const fallback = fallbackCountries.get(code) || {};
|
|
||||||
const real = realCountries.get(code) || {};
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
currency: fallback.currency || real.currencyCode || "-",
|
|
||||||
label: real.countryDisplayName || real.countryName || fallback.label || code,
|
|
||||||
region: regionNameForCountry(code, regions) || fallback.region || "-"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function regionNameForCountry(countryCode, regions) {
|
|
||||||
const normalizedCountryCode = String(countryCode || "").toUpperCase();
|
|
||||||
const region = regions.find((item) =>
|
|
||||||
(item.countries || []).some((code) => String(code || "").toUpperCase() === normalizedCountryCode)
|
|
||||||
);
|
|
||||||
if (!region) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return [region.name, region.regionCode].filter(Boolean).join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({ label, tone, value }) {
|
|
||||||
return (
|
|
||||||
<div className={`money-country-metric money-country-metric--${tone}`}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ratio(value, total) {
|
|
||||||
if (!total) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (Number(value) / Number(total)) * 100;
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
|
||||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
|
||||||
export function MoneyHeader({
|
|
||||||
activeOperatorId,
|
|
||||||
appCode,
|
|
||||||
appOptions,
|
|
||||||
countryCode,
|
|
||||||
countryOptions,
|
|
||||||
masterDataError,
|
|
||||||
masterDataLoading,
|
|
||||||
mode,
|
|
||||||
onActiveOperatorChange,
|
|
||||||
onAppChange,
|
|
||||||
onCountryChange,
|
|
||||||
onExport,
|
|
||||||
onOperatorKeywordChange,
|
|
||||||
onRegionChange,
|
|
||||||
onReset,
|
|
||||||
operatorKeyword,
|
|
||||||
operators,
|
|
||||||
regionId,
|
|
||||||
regionOptions,
|
|
||||||
view
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<header className="money-header">
|
|
||||||
<div className="money-header-title">
|
|
||||||
<h1>{view.title}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="money-header-tools">
|
|
||||||
<div className="money-filter-group">
|
|
||||||
<FilterSelect disabled={masterDataLoading && appOptions.length <= 1} label="App" options={appOptions} value={appCode} onChange={onAppChange} />
|
|
||||||
<FilterSelect
|
|
||||||
disabled={masterDataLoading && countryOptions.length <= 1}
|
|
||||||
label="国家"
|
|
||||||
options={countryOptions}
|
|
||||||
value={countryCode}
|
|
||||||
onChange={onCountryChange}
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
|
||||||
disabled={masterDataLoading && regionOptions.length <= 1}
|
|
||||||
label="区域"
|
|
||||||
options={regionOptions}
|
|
||||||
value={regionId}
|
|
||||||
onChange={onRegionChange}
|
|
||||||
/>
|
|
||||||
{mode === "finance" ? (
|
|
||||||
<label className="money-filter money-filter--search">
|
|
||||||
<span>运营人员</span>
|
|
||||||
<input placeholder="姓名 / 部门" value={operatorKeyword} onChange={(event) => onOperatorKeywordChange(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<FilterSelect
|
|
||||||
label="当前运营"
|
|
||||||
options={operators.map((operator) => [operator.id, operator.label])}
|
|
||||||
value={activeOperatorId}
|
|
||||||
onChange={onActiveOperatorChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{masterDataError ? <span className="money-filter-status">主数据加载失败</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="money-actions">
|
|
||||||
<button className="money-button" onClick={onReset} type="button">
|
|
||||||
<RestartAltOutlined fontSize="small" />
|
|
||||||
重置
|
|
||||||
</button>
|
|
||||||
<button className="money-button money-button--primary" onClick={onExport} type="button">
|
|
||||||
<FileDownloadOutlined fontSize="small" />
|
|
||||||
导出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSelect({ disabled = false, label, onChange, options, value }) {
|
|
||||||
return (
|
|
||||||
<label className="money-filter">
|
|
||||||
<span>{label}</span>
|
|
||||||
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
|
|
||||||
{options.map(([optionValue, optionLabel]) => (
|
|
||||||
<option key={optionValue || "all"} value={optionValue}>
|
|
||||||
{optionLabel}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export function MoneyKpis({ summary }) {
|
|
||||||
const cards = [
|
|
||||||
["总充值", formatUsd(summary.rechargeUsd), `ARPPU ${formatUsd(summary.arppuUsd)}`, "success"],
|
|
||||||
["App 流水", formatUsd(summary.revenueUsd), `毛利 ${formatUsd(summary.grossProfitUsd)}`, "primary"],
|
|
||||||
["存量工资", formatUsd(summary.salaryUsd), `日目标 ${formatPercent(summary.dayProgress)}`, "warning"],
|
|
||||||
["新增 / 日活", formatNumber(summary.newUsers), `DAU ${formatNumber(summary.dau)}`, "info"],
|
|
||||||
["支付链接转化", formatPercent(summary.linkConversion), `${formatNumber(summary.paidLinkCount)} / ${formatNumber(summary.totalLinkCount)} 已支付`, "neutral"]
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="money-kpis">
|
|
||||||
{cards.map(([label, value, sub, tone]) => (
|
|
||||||
<article className={`money-kpi money-kpi--${tone}`} key={label}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
<small>{sub}</small>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
import { MoneyKpis } from "./MoneyKpis.jsx";
|
|
||||||
import { MoneyTrendPanel } from "./MoneyTrendPanel.jsx";
|
|
||||||
import { formatDateTime, formatNumber, formatPercent, formatUsd } from "../format.js";
|
|
||||||
import { paymentStatusLabel } from "../data.js";
|
|
||||||
|
|
||||||
export function MoneyOverview({ links, modules, summary }) {
|
|
||||||
const topModules = [...modules].sort((a, b) => b.rechargeUsd - a.rechargeUsd).slice(0, 4);
|
|
||||||
const recentLinks = links.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MoneyKpis summary={summary} />
|
|
||||||
<MoneyTrendPanel summary={summary} />
|
|
||||||
<section className="money-workbench-grid">
|
|
||||||
<article className="money-panel money-focus-panel">
|
|
||||||
<div className="money-panel-head">
|
|
||||||
<div>
|
|
||||||
<h2>高价值区域</h2>
|
|
||||||
<span>Top 4</span>
|
|
||||||
</div>
|
|
||||||
<strong>{formatUsd(summary.grossProfitUsd)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-focus-list">
|
|
||||||
{topModules.map((module, index) => (
|
|
||||||
<div className="money-focus-row" key={module.id}>
|
|
||||||
<b>{String(index + 1).padStart(2, "0")}</b>
|
|
||||||
<span>
|
|
||||||
<strong>{module.moduleName}</strong>
|
|
||||||
<small>{module.operatorName} · {module.countryName}</small>
|
|
||||||
</span>
|
|
||||||
<em>{formatUsd(module.rechargeUsd)}</em>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="money-panel money-focus-panel">
|
|
||||||
<div className="money-panel-head">
|
|
||||||
<div>
|
|
||||||
<h2>支付链接动态</h2>
|
|
||||||
<span>Last 4</span>
|
|
||||||
</div>
|
|
||||||
<strong>{formatPercent(summary.linkConversion)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-focus-list">
|
|
||||||
{recentLinks.map((link) => (
|
|
||||||
<div className="money-focus-row" key={link.id}>
|
|
||||||
<b>{link.countryCode}</b>
|
|
||||||
<span>
|
|
||||||
<strong>{link.moduleName}</strong>
|
|
||||||
<small>{formatDateTime(link.createdAtMs)} · {paymentStatusLabel(link.status)}</small>
|
|
||||||
</span>
|
|
||||||
<em>{formatUsd(link.amountUsd)}</em>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="money-panel money-focus-panel money-focus-panel--wide">
|
|
||||||
<div className="money-panel-head">
|
|
||||||
<div>
|
|
||||||
<h2>经营信号</h2>
|
|
||||||
<span>D0</span>
|
|
||||||
</div>
|
|
||||||
<strong>{formatNumber(summary.newUsers)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-signal-grid">
|
|
||||||
<Signal label="D目标" value={formatPercent(summary.dayProgress)} tone={summary.dayProgress >= 100 ? "success" : "warning"} />
|
|
||||||
<Signal label="新增质量" value={`次留 ${formatPercent(summary.retention1)}`} tone="info" />
|
|
||||||
<Signal label="活跃规模" value={`DAU ${formatNumber(summary.dau)}`} tone="neutral" />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Signal({ label, tone, value }) {
|
|
||||||
return (
|
|
||||||
<div className={`money-signal money-signal--${tone}`}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
|
||||||
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
|
||||||
import DashboardCustomizeOutlined from "@mui/icons-material/DashboardCustomizeOutlined";
|
|
||||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
|
||||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
|
||||||
import TableRowsOutlined from "@mui/icons-material/TableRowsOutlined";
|
|
||||||
import { formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export const MONEY_VIEWS = [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "财务总览",
|
|
||||||
title: "财务总览",
|
|
||||||
Icon: DashboardCustomizeOutlined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "modules",
|
|
||||||
label: "区域绩效",
|
|
||||||
title: "区域绩效",
|
|
||||||
Icon: TableRowsOutlined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "operators",
|
|
||||||
label: "运营绩效",
|
|
||||||
title: "运营绩效",
|
|
||||||
Icon: GroupsOutlined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "payments",
|
|
||||||
label: "支付链接",
|
|
||||||
title: "支付链接",
|
|
||||||
Icon: AddLinkOutlined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "countries",
|
|
||||||
label: "国家渠道",
|
|
||||||
title: "国家渠道",
|
|
||||||
Icon: PublicOutlined
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function MoneySidebar({ activeOperator, activeView, mode, onModeChange, onViewChange, session, summary }) {
|
|
||||||
return (
|
|
||||||
<aside className="money-sidebar">
|
|
||||||
<div className="money-sidebar-brand">
|
|
||||||
<div className="money-sidebar-mark">
|
|
||||||
<AccountBalanceWalletOutlined fontSize="small" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Money OS</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="money-sidebar-nav" aria-label="财务系统菜单">
|
|
||||||
{MONEY_VIEWS.map(({ Icon, id, label }) => (
|
|
||||||
<button
|
|
||||||
aria-current={activeView === id ? "page" : undefined}
|
|
||||||
className={activeView === id ? "is-active" : ""}
|
|
||||||
key={id}
|
|
||||||
onClick={() => onViewChange(id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
<span>
|
|
||||||
<strong>{label}</strong>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section className="money-sidebar-card">
|
|
||||||
<span>MODE</span>
|
|
||||||
<div className="money-mode-switch" role="tablist" aria-label="财务视角">
|
|
||||||
<button className={mode === "finance" ? "is-active" : ""} onClick={() => onModeChange("finance")} role="tab" type="button">
|
|
||||||
FIN
|
|
||||||
</button>
|
|
||||||
<button className={mode === "operator" ? "is-active" : ""} onClick={() => onModeChange("operator")} role="tab" type="button">
|
|
||||||
OPS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<strong>{mode === "operator" ? activeOperator.label : "ALL OPS"}</strong>
|
|
||||||
<small>{mode === "operator" ? activeOperator.department : "FINANCE"}</small>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="money-sidebar-metric">
|
|
||||||
<PaymentsOutlined fontSize="small" />
|
|
||||||
<div>
|
|
||||||
<span>D0 充值</span>
|
|
||||||
<strong>{formatUsd(summary.rechargeUsd)}</strong>
|
|
||||||
<small>CVR {formatPercent(summary.linkConversion)}</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="money-sidebar-user">
|
|
||||||
<span>{session?.user?.username || "admin"}</span>
|
|
||||||
<small>money:view</small>
|
|
||||||
</footer>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { EChart } from "../charts/EChart.jsx";
|
|
||||||
import { trendLabels } from "../data.js";
|
|
||||||
import { formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export function MoneyTrendPanel({ summary }) {
|
|
||||||
return (
|
|
||||||
<section className="money-analysis">
|
|
||||||
<article className="money-panel money-panel--chart">
|
|
||||||
<PanelHead label="充值与流水趋势" meta="近 7 日" value={formatUsd(summary.revenueUsd)} />
|
|
||||||
<EChart className="money-trend-chart" option={createTrendOption(summary)} />
|
|
||||||
</article>
|
|
||||||
<article className="money-panel money-panel--targets">
|
|
||||||
<PanelHead label="目标达成" meta="当日 / 当月" value={formatPercent(summary.monthProgress)} />
|
|
||||||
<ProgressRow label="当日充值" percent={summary.dayProgress} value={`${formatUsd(summary.rechargeUsd)} / ${formatUsd(summary.dayTargetUsd)}`} />
|
|
||||||
<ProgressRow label="当月充值" percent={summary.monthProgress} value={`${formatUsd(summary.monthActualUsd)} / ${formatUsd(summary.monthTargetUsd)}`} />
|
|
||||||
<div className="money-retention">
|
|
||||||
<span>次留 {formatPercent(summary.retention1)}</span>
|
|
||||||
<span>7日 {formatPercent(summary.retention7)}</span>
|
|
||||||
<span>30日 {formatPercent(summary.retention30)}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PanelHead({ label, meta, value }) {
|
|
||||||
return (
|
|
||||||
<div className="money-panel-head">
|
|
||||||
<div>
|
|
||||||
<h2>{label}</h2>
|
|
||||||
<span>{meta}</span>
|
|
||||||
</div>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressRow({ label, percent, value }) {
|
|
||||||
return (
|
|
||||||
<div className="money-progress">
|
|
||||||
<div>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{formatPercent(percent)}</strong>
|
|
||||||
</div>
|
|
||||||
<i>
|
|
||||||
<b style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
|
|
||||||
</i>
|
|
||||||
<small>{value}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTrendOption(summary) {
|
|
||||||
return {
|
|
||||||
animationDuration: 520,
|
|
||||||
color: ["#2563eb", "#16a34a"],
|
|
||||||
grid: { bottom: 30, containLabel: true, left: 6, right: 16, top: 24 },
|
|
||||||
legend: {
|
|
||||||
bottom: 0,
|
|
||||||
icon: "roundRect",
|
|
||||||
itemHeight: 8,
|
|
||||||
itemWidth: 16,
|
|
||||||
textStyle: { color: "#64748b" }
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
borderColor: "#d8e0ec",
|
|
||||||
trigger: "axis",
|
|
||||||
textStyle: { color: "#0f172a" },
|
|
||||||
valueFormatter: (value) => formatUsd(value)
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
axisLine: { lineStyle: { color: "rgba(100, 116, 139, 0.18)" } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
data: trendLabels,
|
|
||||||
type: "category"
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
axisLabel: { formatter: (value) => `${Math.round(value / 1000)}k` },
|
|
||||||
splitLine: { lineStyle: { color: "rgba(100, 116, 139, 0.16)" } },
|
|
||||||
type: "value"
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
areaStyle: { opacity: 0.12 },
|
|
||||||
data: summary.rechargeTrend,
|
|
||||||
name: "充值",
|
|
||||||
smooth: true,
|
|
||||||
symbol: "circle",
|
|
||||||
symbolSize: 6,
|
|
||||||
type: "line"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
barMaxWidth: 18,
|
|
||||||
data: summary.revenueTrend,
|
|
||||||
name: "流水",
|
|
||||||
type: "bar"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
import { summarizeMoneyModules } from "../data.js";
|
|
||||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export function OperatorPerformancePanel({ modules }) {
|
|
||||||
const rows = moneyOperatorsFromModules(modules)
|
|
||||||
.filter((operator) => operator.modules.length > 0)
|
|
||||||
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="money-operator-board">
|
|
||||||
<div className="money-operator-cards">
|
|
||||||
{rows.map((operator, index) => (
|
|
||||||
<article className="money-panel money-operator-card" key={operator.id}>
|
|
||||||
<div className="money-rank">{String(index + 1).padStart(2, "0")}</div>
|
|
||||||
<span>{operator.department}</span>
|
|
||||||
<h2>{operator.label}</h2>
|
|
||||||
<strong>{formatUsd(operator.summary.rechargeUsd)}</strong>
|
|
||||||
<div className="money-operator-meta">
|
|
||||||
<small>{operator.modules.length} 个区域</small>
|
|
||||||
<small>M {formatPercent(operator.summary.monthProgress)}</small>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article className="money-panel money-table-panel">
|
|
||||||
<div className="money-table-head">
|
|
||||||
<div>
|
|
||||||
<h2>运营绩效排行</h2>
|
|
||||||
<span>{rows.length} operators</span>
|
|
||||||
</div>
|
|
||||||
<strong>{rows.length} 人</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-table-scroll">
|
|
||||||
<table className="money-table money-table--compact">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>运营</th>
|
|
||||||
<th>负责区域</th>
|
|
||||||
<th>充值</th>
|
|
||||||
<th>流水</th>
|
|
||||||
<th>存量工资</th>
|
|
||||||
<th>毛利</th>
|
|
||||||
<th>新增 / 日活</th>
|
|
||||||
<th>日目标</th>
|
|
||||||
<th>月目标</th>
|
|
||||||
<th>链接转化</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((operator) => (
|
|
||||||
<tr key={operator.id}>
|
|
||||||
<td>
|
|
||||||
<Stack primary={operator.label} secondary={operator.department} />
|
|
||||||
</td>
|
|
||||||
<td>{operator.modules.length}</td>
|
|
||||||
<td className="money-num money-num--success">{formatUsd(operator.summary.rechargeUsd)}</td>
|
|
||||||
<td className="money-num">{formatUsd(operator.summary.revenueUsd)}</td>
|
|
||||||
<td className="money-num money-num--warning">{formatUsd(operator.summary.salaryUsd)}</td>
|
|
||||||
<td className="money-num money-num--primary">{formatUsd(operator.summary.grossProfitUsd)}</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={formatNumber(operator.summary.newUsers)} secondary={`DAU ${formatNumber(operator.summary.dau)}`} />
|
|
||||||
</td>
|
|
||||||
<td>{formatPercent(operator.summary.dayProgress)}</td>
|
|
||||||
<td>{formatPercent(operator.summary.monthProgress)}</td>
|
|
||||||
<td>{formatPercent(operator.summary.linkConversion)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moneyOperatorsFromModules(modules) {
|
|
||||||
const byOperator = new Map();
|
|
||||||
modules.forEach((module) => {
|
|
||||||
const id = module.operatorId || "0";
|
|
||||||
if (!byOperator.has(id)) {
|
|
||||||
byOperator.set(id, {
|
|
||||||
department: module.department || "",
|
|
||||||
id,
|
|
||||||
label: module.operatorName || id,
|
|
||||||
modules: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
byOperator.get(id).modules.push(module);
|
|
||||||
});
|
|
||||||
return [...byOperator.values()].map((operator) => ({
|
|
||||||
...operator,
|
|
||||||
summary: summarizeMoneyModules(operator.modules)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stack({ primary, secondary }) {
|
|
||||||
return (
|
|
||||||
<span className="money-stack">
|
|
||||||
<strong>{primary || "-"}</strong>
|
|
||||||
<small>{secondary || "-"}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,560 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
|
||||||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
|
||||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
|
||||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
|
||||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
|
||||||
import { formatDateTime, formatUsd } from "../format.js";
|
|
||||||
import {
|
|
||||||
createAdminTemporaryPaymentLink,
|
|
||||||
listAdminPaymentMethods,
|
|
||||||
listAdminTemporaryPaymentLinks
|
|
||||||
} from "../paymentLinksApi.js";
|
|
||||||
import {
|
|
||||||
buildTemporaryCountries,
|
|
||||||
defaultReturnUrl,
|
|
||||||
dollarsToMinor,
|
|
||||||
temporaryCountryCode,
|
|
||||||
temporaryCountryLabel,
|
|
||||||
temporaryLogoText,
|
|
||||||
temporaryMethodLabel
|
|
||||||
} from "../temporaryPaymentApi.js";
|
|
||||||
import { PanelHead } from "./MoneyTrendPanel.jsx";
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
["", "全部状态"],
|
|
||||||
["pending", "待支付"],
|
|
||||||
["redirected", "已生成"],
|
|
||||||
["paid", "已支付"],
|
|
||||||
["failed", "失败"],
|
|
||||||
["credited", "已入账"]
|
|
||||||
];
|
|
||||||
|
|
||||||
export function PaymentLinkPanel({ links = [], modules = [], onCopy, onCreated }) {
|
|
||||||
const [keyword, setKeyword] = useState("");
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [query, setQuery] = useState({ keyword: "", status: "" });
|
|
||||||
const [state, setState] = useState({ error: "", items: [], loading: true });
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [methods, setMethods] = useState([]);
|
|
||||||
const [methodsState, setMethodsState] = useState({ error: "", loading: false });
|
|
||||||
const [createState, setCreateState] = useState({ error: "", loading: false });
|
|
||||||
const [form, setForm] = useState(() => ({
|
|
||||||
appCode: "",
|
|
||||||
countryCode: "",
|
|
||||||
methodId: "",
|
|
||||||
moduleId: "",
|
|
||||||
usdAmount: "99"
|
|
||||||
}));
|
|
||||||
|
|
||||||
const appOptions = useMemo(() => appOptionsFromModules(modules), [modules]);
|
|
||||||
const appCodes = useMemo(() => appOptions.map(([appCode]) => appCode), [appOptions]);
|
|
||||||
const appCodesKey = appCodes.join("|");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setForm((current) => {
|
|
||||||
if (current.appCode && appCodes.includes(current.appCode)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
appCode: appCodes[0] || "",
|
|
||||||
countryCode: "",
|
|
||||||
methodId: "",
|
|
||||||
moduleId: ""
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [appCodes, appCodesKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
if (!appCodes.length) {
|
|
||||||
setState({ error: "", items: [], loading: false });
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
setState((current) => ({ ...current, error: "", loading: true }));
|
|
||||||
Promise.all(appCodes.map((appCode) => listAdminTemporaryPaymentLinks({ ...query, appCode })))
|
|
||||||
.then((pages) => {
|
|
||||||
if (mounted) {
|
|
||||||
setState({ error: "", items: mergeLinks(pages.flatMap((page) => normalizeLinks(page, modules))), loading: false });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setState({ error: err.message || "加载支付链接失败", items: [], loading: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [appCodes, appCodesKey, modules, query]);
|
|
||||||
|
|
||||||
const countries = useMemo(() => buildTemporaryCountries(methods), [methods]);
|
|
||||||
const methodsForCountry = useMemo(
|
|
||||||
() =>
|
|
||||||
methods
|
|
||||||
.filter((method) => temporaryCountryCode(method) === form.countryCode)
|
|
||||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0) || temporaryMethodLabel(a).localeCompare(temporaryMethodLabel(b))),
|
|
||||||
[form.countryCode, methods]
|
|
||||||
);
|
|
||||||
const regionOptions = useMemo(() => regionsForCountry(modules, form.appCode, form.countryCode), [form.appCode, form.countryCode, modules]);
|
|
||||||
const selectedMethod = methodsForCountry.find((method) => String(method.method_id) === String(form.methodId));
|
|
||||||
const selectedRegion = regionOptions.find((option) => option.value === form.moduleId);
|
|
||||||
const selectedModule = selectedRegion?.module;
|
|
||||||
const rows = useMemo(() => mergeLinks([...state.items, ...links.map((link) => normalizeLegacyLink(link, modules))]), [links, modules, state.items]);
|
|
||||||
const canCreate = Boolean(form.appCode && form.countryCode && form.moduleId && selectedMethod && selectedModule && dollarsToMinor(form.usdAmount) > 0 && !createState.loading);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialogOpen || !form.appCode) {
|
|
||||||
setMethods([]);
|
|
||||||
setMethodsState({ error: "", loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mounted = true;
|
|
||||||
setMethodsState({ error: "", loading: true });
|
|
||||||
listAdminPaymentMethods(form.appCode)
|
|
||||||
.then((nextMethods) => {
|
|
||||||
if (mounted) {
|
|
||||||
const activeMethods = nextMethods.filter((method) => !method.status || method.status === "active");
|
|
||||||
setMethods(activeMethods);
|
|
||||||
setMethodsState({ error: activeMethods.length ? "" : "当前无数据", loading: false });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setMethods([]);
|
|
||||||
setMethodsState({ error: err.message || "支付方式加载失败", loading: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [dialogOpen, form.appCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!countries.length) {
|
|
||||||
setForm((current) => (current.countryCode ? { ...current, countryCode: "", methodId: "", moduleId: "" } : current));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm((current) => (countries.some((country) => country.code === current.countryCode) ? current : { ...current, countryCode: countries[0].code, methodId: "", moduleId: "" }));
|
|
||||||
}, [countries]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!methodsForCountry.length) {
|
|
||||||
setForm((current) => (current.methodId ? { ...current, methodId: "" } : current));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm((current) =>
|
|
||||||
methodsForCountry.some((method) => String(method.method_id) === String(current.methodId))
|
|
||||||
? current
|
|
||||||
: { ...current, methodId: String(methodsForCountry[0].method_id) }
|
|
||||||
);
|
|
||||||
}, [methodsForCountry]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!regionOptions.length) {
|
|
||||||
setForm((current) => (current.moduleId ? { ...current, moduleId: "" } : current));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm((current) => (regionOptions.some((option) => option.value === current.moduleId) ? current : { ...current, moduleId: regionOptions[0].value }));
|
|
||||||
}, [regionOptions]);
|
|
||||||
|
|
||||||
const submit = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setQuery({ keyword: keyword.trim(), status });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateForm = (key) => (value) => {
|
|
||||||
setForm((current) => {
|
|
||||||
const next = { ...current, [key]: value };
|
|
||||||
if (key === "appCode") {
|
|
||||||
return { ...next, countryCode: "", methodId: "", moduleId: "" };
|
|
||||||
}
|
|
||||||
if (key === "countryCode") {
|
|
||||||
return { ...next, methodId: "", moduleId: "" };
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitCreate = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const usdMinorAmount = dollarsToMinor(form.usdAmount);
|
|
||||||
if (!canCreate || !selectedMethod || !selectedModule || usdMinorAmount <= 0) {
|
|
||||||
setCreateState({ error: "参数不完整", loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreateState({ error: "", loading: true });
|
|
||||||
try {
|
|
||||||
const data = await createAdminTemporaryPaymentLink({
|
|
||||||
appCode: form.appCode,
|
|
||||||
paymentMethodId: Number(selectedMethod.method_id),
|
|
||||||
providerCode: selectedMethod.provider_code,
|
|
||||||
regionId: Number(selectedModule.regionId),
|
|
||||||
returnUrl: defaultReturnUrl(form.appCode),
|
|
||||||
usdMinorAmount
|
|
||||||
});
|
|
||||||
const link = normalizeCreatedLink(data, {
|
|
||||||
method: selectedMethod,
|
|
||||||
module: selectedModule
|
|
||||||
});
|
|
||||||
setState((current) => ({ ...current, items: mergeLinks([link, ...current.items]) }));
|
|
||||||
onCreated?.(link);
|
|
||||||
setDialogOpen(false);
|
|
||||||
setCreateState({ error: "", loading: false });
|
|
||||||
setQuery({ keyword: keyword.trim(), status });
|
|
||||||
} catch (err) {
|
|
||||||
setCreateState({ error: err.message || "创建失败", loading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="money-panel money-link-panel">
|
|
||||||
<PanelHead
|
|
||||||
label="支付链接"
|
|
||||||
meta={state.error || `${modules.length} 个区域`}
|
|
||||||
value={state.loading ? "..." : String(rows.length)}
|
|
||||||
/>
|
|
||||||
<form className="money-link-form" onSubmit={submit}>
|
|
||||||
<label className="money-field money-filter--search">
|
|
||||||
<span>搜索</span>
|
|
||||||
<input placeholder="订单 / 用户 / 国家" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<Select label="状态" value={status} onChange={setStatus} options={statusOptions} />
|
|
||||||
<button className="money-button" disabled={state.loading} type="submit">
|
|
||||||
<SearchOutlined fontSize="small" />
|
|
||||||
查询
|
|
||||||
</button>
|
|
||||||
<button className="money-button" disabled={state.loading} onClick={() => setQuery({ keyword: keyword.trim(), status })} type="button">
|
|
||||||
<RefreshOutlined fontSize="small" />
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
<button className="money-button money-button--primary" disabled={!appOptions.length} onClick={() => setDialogOpen(true)} type="button">
|
|
||||||
<AddLinkOutlined fontSize="small" />
|
|
||||||
创建支付链接
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div className="money-link-list">
|
|
||||||
{rows.map((link) => (
|
|
||||||
<article className="money-link-row" key={link.id}>
|
|
||||||
<div>
|
|
||||||
<strong>{link.moduleName}</strong>
|
|
||||||
<span>{`${link.countryName} / ${link.providerName} / ${formatUsd(link.amountUsd)}`}</span>
|
|
||||||
<code title={link.payUrl}>{link.payUrl || "-"}</code>
|
|
||||||
</div>
|
|
||||||
<aside>
|
|
||||||
<span className={`money-status ${link.status === "paid" || link.status === "credited" ? "money-status--success" : ""}`}>
|
|
||||||
{paymentStatusLabel(link.status)}
|
|
||||||
</span>
|
|
||||||
<small>{formatDateTime(link.createdAtMs)}</small>
|
|
||||||
</aside>
|
|
||||||
<nav>
|
|
||||||
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
|
|
||||||
<ContentCopyOutlined fontSize="small" />
|
|
||||||
</button>
|
|
||||||
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
|
|
||||||
<OpenInNewOutlined fontSize="small" />
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
{!rows.length ? <div className="money-payment-empty">当前无数据</div> : null}
|
|
||||||
</div>
|
|
||||||
{dialogOpen ? (
|
|
||||||
<CreatePaymentDialog
|
|
||||||
appOptions={appOptions}
|
|
||||||
canCreate={canCreate}
|
|
||||||
countries={countries}
|
|
||||||
createState={createState}
|
|
||||||
form={form}
|
|
||||||
methodsForCountry={methodsForCountry}
|
|
||||||
methodsState={methodsState}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
onSubmit={submitCreate}
|
|
||||||
onUpdate={updateForm}
|
|
||||||
regionOptions={regionOptions}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreatePaymentDialog({
|
|
||||||
appOptions,
|
|
||||||
canCreate,
|
|
||||||
countries,
|
|
||||||
createState,
|
|
||||||
form,
|
|
||||||
methodsForCountry,
|
|
||||||
methodsState,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
onUpdate,
|
|
||||||
regionOptions
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="money-modal-backdrop" role="presentation">
|
|
||||||
<section className="money-payment-dialog" role="dialog" aria-modal="true" aria-label="创建支付链接">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2>创建支付链接</h2>
|
|
||||||
<span>{methodsState.loading ? "加载支付方式" : `${countries.length} 个国家`}</span>
|
|
||||||
</div>
|
|
||||||
<button className="money-icon-button" onClick={onClose} type="button" aria-label="关闭">
|
|
||||||
<CloseOutlined fontSize="small" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<form className="money-create-form" onSubmit={onSubmit}>
|
|
||||||
<div className="money-create-grid">
|
|
||||||
<Select label="App" value={form.appCode} onChange={onUpdate("appCode")} options={appOptions} />
|
|
||||||
<Select label="国家" value={form.countryCode} onChange={onUpdate("countryCode")} options={countries.map((country) => [country.code, temporaryCountryLabel(country)])} />
|
|
||||||
<Select label="区域" value={form.moduleId} onChange={onUpdate("moduleId")} options={regionOptions.map((option) => [option.value, option.label])} />
|
|
||||||
<label className="money-field">
|
|
||||||
<span>金额 USD</span>
|
|
||||||
<input inputMode="decimal" placeholder="99.00" value={form.usdAmount} onChange={(event) => onUpdate("usdAmount")(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<section className="money-method-select">
|
|
||||||
<div className="money-method-select-head">
|
|
||||||
<strong>支付方式</strong>
|
|
||||||
<span>{methodsForCountry.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="money-method-options money-method-options--dialog">
|
|
||||||
{methodsForCountry.length ? (
|
|
||||||
methodsForCountry.map((method) => (
|
|
||||||
<MethodButton
|
|
||||||
active={String(method.method_id) === String(form.methodId)}
|
|
||||||
key={method.method_id}
|
|
||||||
method={method}
|
|
||||||
onClick={() => onUpdate("methodId")(String(method.method_id))}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="money-payment-empty">{methodsState.loading ? "加载中" : methodsState.error || "当前无数据"}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<footer>
|
|
||||||
{createState.error ? <span className="money-payment-message money-payment-message--error">{createState.error}</span> : <span />}
|
|
||||||
<button className="money-button money-button--primary" disabled={!canCreate} type="submit">
|
|
||||||
<AddLinkOutlined fontSize="small" />
|
|
||||||
{createState.loading ? "创建中" : "创建链接"}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Select({ label, onChange, options, value }) {
|
|
||||||
return (
|
|
||||||
<label className="money-field">
|
|
||||||
<span>{label}</span>
|
|
||||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
|
||||||
{options.length ? options.map(([optionValue, optionLabel]) => (
|
|
||||||
<option key={optionValue || "all"} value={optionValue}>
|
|
||||||
{optionLabel}
|
|
||||||
</option>
|
|
||||||
)) : <option value="">当前无数据</option>}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MethodButton({ active, method, onClick }) {
|
|
||||||
return (
|
|
||||||
<button className={active ? "money-method-option is-active" : "money-method-option"} onClick={onClick} type="button">
|
|
||||||
<span className="money-method-logo">{method.logo_url ? <img alt={method.method_name || method.pay_type || "pay"} src={method.logo_url} /> : temporaryLogoText(method)}</span>
|
|
||||||
<span className="money-method-copy">
|
|
||||||
<strong>{method.method_name || method.pay_type || method.pay_way || "-"}</strong>
|
|
||||||
<small>{[method.provider_name || method.provider_code, method.pay_way, method.pay_type].filter(Boolean).join(" / ")}</small>
|
|
||||||
</span>
|
|
||||||
<span className="money-method-tags">
|
|
||||||
<b>{method.currency_code || "-"}</b>
|
|
||||||
<em>ID {method.method_id}</em>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLinks(data, modules) {
|
|
||||||
const items = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
|
||||||
return items.map((item) => normalizeAdminLink(item, modules)).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAdminLink(item, modules = []) {
|
|
||||||
const order = item.order || item;
|
|
||||||
const id = stringValue(order.orderId ?? order.order_id ?? order.id);
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const appCode = stringValue(order.appCode ?? order.app_code);
|
|
||||||
const regionId = numberValue(order.regionId ?? order.region_id);
|
|
||||||
const ownerUserId = stringValue(order.ownerUserId ?? order.owner_user_id);
|
|
||||||
const module = moduleForLink(modules, appCode, regionId, ownerUserId);
|
|
||||||
const usdMinorAmount = order.usdMinorAmount ?? order.usd_minor_amount;
|
|
||||||
return {
|
|
||||||
amountUsd: usdMinorAmount === 0 || usdMinorAmount ? Number(usdMinorAmount) / 100 : Number(order.amountUsd ?? order.amount_usd ?? 0),
|
|
||||||
appCode,
|
|
||||||
countryCode: stringValue(order.countryCode ?? order.country_code),
|
|
||||||
countryName: stringValue(order.countryName ?? order.country_name ?? order.countryCode ?? order.country_code) || "-",
|
|
||||||
createdAtMs: order.createdAtMs ?? order.created_at_ms ?? order.createdAt ?? order.created_at,
|
|
||||||
id,
|
|
||||||
moduleId: module?.id || scopeKey(appCode, regionId),
|
|
||||||
moduleName: module?.moduleName || [appCode, regionId ? `区域 ${regionId}` : ""].filter(Boolean).join(" / ") || "Temporary Pay",
|
|
||||||
ownerUserId,
|
|
||||||
payUrl: stringValue(item.paymentLink ?? item.payment_link ?? order.payUrl ?? order.pay_url),
|
|
||||||
providerName: stringValue(order.providerName ?? order.provider_name ?? order.providerCode ?? order.provider_code) || "-",
|
|
||||||
region: module?.region || (regionId ? String(regionId) : ""),
|
|
||||||
regionId,
|
|
||||||
status: stringValue(order.status)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLegacyLink(link, modules) {
|
|
||||||
if (!link) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const module = moduleForLink(modules, link.appCode, link.regionId, link.ownerUserId) || modules.find((item) => item.id === link.moduleId);
|
|
||||||
return {
|
|
||||||
amountUsd: Number(link.amountUsd || 0),
|
|
||||||
appCode: link.appCode || module?.appCode || "",
|
|
||||||
countryCode: link.countryCode,
|
|
||||||
countryName: link.countryName || link.countryCode || "-",
|
|
||||||
createdAtMs: link.createdAtMs,
|
|
||||||
id: link.id,
|
|
||||||
moduleId: link.moduleId || module?.id || "",
|
|
||||||
moduleName: link.moduleName || module?.moduleName || "-",
|
|
||||||
ownerUserId: link.ownerUserId,
|
|
||||||
payUrl: link.payUrl || "",
|
|
||||||
providerName: link.providerName || "-",
|
|
||||||
region: link.region || module?.region || "",
|
|
||||||
regionId: link.regionId || module?.regionId || 0,
|
|
||||||
status: link.status || ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCreatedLink(data, { method, module }) {
|
|
||||||
return normalizeAdminLink(data, [module]) || {
|
|
||||||
amountUsd: dollarsToMinor("0") / 100,
|
|
||||||
appCode: module.appCode,
|
|
||||||
countryCode: temporaryCountryCode(method),
|
|
||||||
countryName: method.country_name || temporaryCountryCode(method),
|
|
||||||
createdAtMs: Date.now(),
|
|
||||||
id: `${module.appCode}:${module.regionId}:${Date.now()}`,
|
|
||||||
moduleId: module.id,
|
|
||||||
moduleName: module.moduleName,
|
|
||||||
payUrl: "",
|
|
||||||
providerName: stringValue(method.provider_name ?? method.provider_code) || "-",
|
|
||||||
region: module.region,
|
|
||||||
regionId: module.regionId,
|
|
||||||
status: "redirected"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function regionsForCountry(modules, appCode, countryCode) {
|
|
||||||
const normalizedAppCode = stringValue(appCode);
|
|
||||||
const normalizedCountryCode = stringValue(countryCode).toUpperCase();
|
|
||||||
const byScope = new Map();
|
|
||||||
modules.forEach((module) => {
|
|
||||||
if (module.appCode !== normalizedAppCode || Number(module.regionId) <= 0 || !moduleCoversCountry(module, normalizedCountryCode)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const key = scopeKey(module.appCode, module.regionId);
|
|
||||||
if (!byScope.has(key)) {
|
|
||||||
byScope.set(key, {
|
|
||||||
label: `${module.moduleName} · ${module.region}`,
|
|
||||||
module,
|
|
||||||
value: key
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [...byScope.values()].sort((a, b) => a.label.localeCompare(b.label, "zh-Hans-CN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleCoversCountry(module, countryCode) {
|
|
||||||
if (!countryCode) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const codes = Array.isArray(module.countryCodes) ? module.countryCodes : [];
|
|
||||||
return module.countryCode === "ALL" || module.countryCode === countryCode || codes.includes(countryCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleForLink(modules, appCode, regionId, ownerUserId) {
|
|
||||||
const normalizedOwner = stringValue(ownerUserId);
|
|
||||||
return (
|
|
||||||
modules.find(
|
|
||||||
(module) =>
|
|
||||||
module.appCode === appCode &&
|
|
||||||
Number(module.regionId) === Number(regionId) &&
|
|
||||||
normalizedOwner &&
|
|
||||||
String(module.operatorId) === normalizedOwner
|
|
||||||
) ||
|
|
||||||
modules.find((module) => module.appCode === appCode && Number(module.regionId) === Number(regionId)) ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appOptionsFromModules(modules) {
|
|
||||||
const byCode = new Map();
|
|
||||||
modules.forEach((module) => {
|
|
||||||
const appCode = stringValue(module.appCode);
|
|
||||||
if (appCode && !byCode.has(appCode)) {
|
|
||||||
byCode.set(appCode, module.appName || appCode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [...byCode.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), "zh-Hans-CN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeLinks(items) {
|
|
||||||
const byId = new Map();
|
|
||||||
items.filter(Boolean).forEach((item) => {
|
|
||||||
if (!item.id || byId.has(item.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
byId.set(item.id, item);
|
|
||||||
});
|
|
||||||
return [...byId.values()].sort((a, b) => Number(b.createdAtMs || 0) - Number(a.createdAtMs || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function paymentStatusLabel(status) {
|
|
||||||
if (status === "paid") {
|
|
||||||
return "已支付";
|
|
||||||
}
|
|
||||||
if (status === "redirected") {
|
|
||||||
return "已生成";
|
|
||||||
}
|
|
||||||
if (status === "pending") {
|
|
||||||
return "待支付";
|
|
||||||
}
|
|
||||||
if (status === "failed") {
|
|
||||||
return "失败";
|
|
||||||
}
|
|
||||||
if (status === "credited") {
|
|
||||||
return "已入账";
|
|
||||||
}
|
|
||||||
return status || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeKey(appCode, regionId) {
|
|
||||||
return `${appCode}:${Number(regionId || 0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberValue(value) {
|
|
||||||
const number = Number(value);
|
|
||||||
return Number.isFinite(number) ? number : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value) {
|
|
||||||
return String(value ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLink(value) {
|
|
||||||
if (value) {
|
|
||||||
window.open(value, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
|
||||||
|
|
||||||
export function PerformanceTable({ meta = "", modules, summary, title = "区域绩效" }) {
|
|
||||||
const rows = [createTotalRow(summary), ...modules];
|
|
||||||
return (
|
|
||||||
<section className="money-panel money-table-panel">
|
|
||||||
<div className="money-table-head">
|
|
||||||
<div>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<span>{meta || `${modules.length} 个区域`}</span>
|
|
||||||
</div>
|
|
||||||
<strong>{modules.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="money-table-scroll">
|
|
||||||
<table className="money-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>负责区域</th>
|
|
||||||
<th>负责人</th>
|
|
||||||
<th>区域类型</th>
|
|
||||||
<th>国家 / 地区</th>
|
|
||||||
<th>充值</th>
|
|
||||||
<th>App 流水</th>
|
|
||||||
<th>存量工资</th>
|
|
||||||
<th>毛利</th>
|
|
||||||
<th>新增 / 日活</th>
|
|
||||||
<th>当日目标</th>
|
|
||||||
<th>当月目标</th>
|
|
||||||
<th>留存</th>
|
|
||||||
<th>支付链接</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<tr className={row.isTotal ? "is-total" : ""} key={row.id}>
|
|
||||||
<td>
|
|
||||||
<Stack primary={row.moduleName} secondary={row.appName} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={row.operatorName} secondary={row.department} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Scope scope={row.scope} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={row.countryName} secondary={row.region} />
|
|
||||||
</td>
|
|
||||||
<td className="money-num money-num--success">{formatUsd(row.rechargeUsd)}</td>
|
|
||||||
<td className="money-num">{formatUsd(row.revenueUsd)}</td>
|
|
||||||
<td className="money-num money-num--warning">{formatUsd(row.salaryUsd)}</td>
|
|
||||||
<td className="money-num money-num--primary">{formatUsd(row.revenueUsd - row.salaryUsd)}</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={formatNumber(row.newUsers)} secondary={`DAU ${formatNumber(row.dau)}`} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MiniProgress actual={row.rechargeUsd} percent={row.dayProgress} target={row.dayTargetUsd} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<MiniProgress actual={row.monthActualUsd} percent={row.monthProgress} target={row.monthTargetUsd} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={`次留 ${formatPercent(row.retention1)}`} secondary={`7日 ${formatPercent(row.retention7)} / 30日 ${formatPercent(row.retention30)}`} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Stack primary={`${formatNumber(row.paidLinkCount)} / ${formatNumber(row.linkCount)}`} secondary={`金币 ${formatNumber(row.coinIssued)}`} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stack({ primary, secondary }) {
|
|
||||||
return (
|
|
||||||
<span className="money-stack">
|
|
||||||
<strong>{primary || "-"}</strong>
|
|
||||||
<small>{secondary || "-"}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Scope({ scope }) {
|
|
||||||
const label = scope === "app" ? "全站" : scope === "region" ? "区域" : "汇总";
|
|
||||||
return <span className={`money-scope money-scope--${scope}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniProgress({ actual, percent, target }) {
|
|
||||||
if (!target) {
|
|
||||||
return <span className="money-empty-tag">无目标</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="money-mini-progress">
|
|
||||||
<span>
|
|
||||||
<b>{formatUsd(actual)}</b>
|
|
||||||
<strong>{formatPercent(percent)}</strong>
|
|
||||||
</span>
|
|
||||||
<i>
|
|
||||||
<em style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
|
|
||||||
</i>
|
|
||||||
<small>{formatUsd(target)}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTotalRow(summary) {
|
|
||||||
return {
|
|
||||||
appName: "全部 App",
|
|
||||||
coinIssued: summary.coinIssued,
|
|
||||||
countryName: "全部国家",
|
|
||||||
dau: summary.dau,
|
|
||||||
dayProgress: summary.dayProgress,
|
|
||||||
dayTargetUsd: summary.dayTargetUsd,
|
|
||||||
department: "财务汇总",
|
|
||||||
id: "__total",
|
|
||||||
isTotal: true,
|
|
||||||
linkCount: summary.totalLinkCount,
|
|
||||||
moduleName: "Total",
|
|
||||||
monthActualUsd: summary.monthActualUsd,
|
|
||||||
monthProgress: summary.monthProgress,
|
|
||||||
monthTargetUsd: summary.monthTargetUsd,
|
|
||||||
newUsers: summary.newUsers,
|
|
||||||
operatorName: "全部负责人",
|
|
||||||
paidLinkCount: summary.paidLinkCount,
|
|
||||||
rechargeUsd: summary.rechargeUsd,
|
|
||||||
region: "Global",
|
|
||||||
retention1: summary.retention1,
|
|
||||||
retention7: summary.retention7,
|
|
||||||
retention30: summary.retention30,
|
|
||||||
revenueUsd: summary.revenueUsd,
|
|
||||||
salaryUsd: summary.salaryUsd,
|
|
||||||
scope: "total"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
export {
|
|
||||||
availableCountriesForModule,
|
|
||||||
availableProvidersForCountry,
|
|
||||||
createPaymentLinkRecord,
|
|
||||||
filterMoneyModules,
|
|
||||||
initialPaymentLinks,
|
|
||||||
moneyAppOptions,
|
|
||||||
moneyCountries,
|
|
||||||
moneyModuleMatchesRegion,
|
|
||||||
moneyModules,
|
|
||||||
moneyOperators,
|
|
||||||
moneyProviderOptions,
|
|
||||||
paymentStatusLabel,
|
|
||||||
summarizeMoneyModules,
|
|
||||||
trendLabels
|
|
||||||
} from "./model/data.js";
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { MoneyApp } from "./MoneyApp.jsx";
|
|
||||||
import "./styles/index.css";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("money-root")).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<MoneyApp />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
|
||||||
import { adminApiGet } from "./api.js";
|
|
||||||
|
|
||||||
export async function fetchMoneyMasterData() {
|
|
||||||
const [scopeResult, performanceResult] = await Promise.allSettled([fetchMoneyScope(), fetchMoneyPerformance()]);
|
|
||||||
const errors = [scopeResult, performanceResult]
|
|
||||||
.filter((result) => result.status === "rejected")
|
|
||||||
.map((result) => result.reason?.message || "主数据加载失败");
|
|
||||||
const scope = scopeResult.status === "fulfilled" ? scopeResult.value : emptyScope();
|
|
||||||
const performance = performanceResult.status === "fulfilled" ? performanceResult.value : { items: [], summary: {} };
|
|
||||||
const modules = buildMoneyModules(scope, performance.items || []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
apps: scope.apps,
|
|
||||||
countries: scope.countries,
|
|
||||||
error: errors.join(";"),
|
|
||||||
modules,
|
|
||||||
performanceSummary: performance.summary || {},
|
|
||||||
regions: scope.regions,
|
|
||||||
scopes: scope.scopes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMoneyScope() {
|
|
||||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyScope));
|
|
||||||
return {
|
|
||||||
all: Boolean(data?.all),
|
|
||||||
apps: normalizeAdminApps(data?.apps || []),
|
|
||||||
countries: normalizeCountries(data?.countries || []),
|
|
||||||
regions: normalizeRegions(data?.regions || []),
|
|
||||||
scopes: normalizeScopes(data?.scopes || [])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMoneyPerformance(query = {}) {
|
|
||||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyPerformance), { query });
|
|
||||||
return {
|
|
||||||
items: Array.isArray(data?.items) ? data.items : [],
|
|
||||||
summary: data?.summary || {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMoneyApps() {
|
|
||||||
return normalizeAdminApps(await adminApiGet(apiEndpointPath(API_OPERATIONS.listApps)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMoneyCountries(query = {}) {
|
|
||||||
return normalizeCountries(await adminApiGet(apiEndpointPath(API_OPERATIONS.listCountries), { query }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listMoneyRegions(query = {}) {
|
|
||||||
return normalizeRegions(await adminApiGet(apiEndpointPath(API_OPERATIONS.listRegions), { query }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAdminApps(data) {
|
|
||||||
return listItems(data)
|
|
||||||
.map((item) => {
|
|
||||||
const appCode = stringValue(item.appCode ?? item.app_code ?? item.code);
|
|
||||||
if (!appCode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
appCode,
|
|
||||||
appId: item.appId ?? item.app_id ?? item.id,
|
|
||||||
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode,
|
|
||||||
packageName: stringValue(item.packageName ?? item.package_name),
|
|
||||||
platform: stringValue(item.platform),
|
|
||||||
status: item.status
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCountries(data) {
|
|
||||||
return listItems(data)
|
|
||||||
.map((item) => {
|
|
||||||
const countryCode = stringValue(item.countryCode ?? item.country_code ?? item.code).toUpperCase();
|
|
||||||
if (!countryCode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
countryCode,
|
|
||||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name ?? item.displayName ?? item.display_name),
|
|
||||||
countryId: item.countryId ?? item.country_id ?? item.id,
|
|
||||||
countryName: stringValue(item.countryName ?? item.country_name ?? item.name),
|
|
||||||
enabled: item.enabled,
|
|
||||||
flag: stringValue(item.flag),
|
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
|
||||||
isoAlpha3: stringValue(item.isoAlpha3 ?? item.iso_alpha3),
|
|
||||||
isoNumeric: stringValue(item.isoNumeric ?? item.iso_numeric),
|
|
||||||
phoneCountryCode: stringValue(item.phoneCountryCode ?? item.phone_country_code),
|
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
|
||||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || countryLabel(a).localeCompare(countryLabel(b), "zh-Hans-CN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRegions(data) {
|
|
||||||
return listItems(data)
|
|
||||||
.map((item) => {
|
|
||||||
const regionId = item.regionId ?? item.region_id ?? item.id;
|
|
||||||
const regionCode = stringValue(item.regionCode ?? item.region_code ?? item.code).toUpperCase();
|
|
||||||
if (!(regionId === 0 || regionId) || regionCode === "GLOBAL") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
|
||||||
countries: normalizeCountryCodes(item.countries ?? item.countryCodes ?? item.country_codes),
|
|
||||||
name: stringValue(item.name ?? item.regionName ?? item.region_name) || regionCode || `区域 ${regionId}`,
|
|
||||||
regionCode,
|
|
||||||
regionId,
|
|
||||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
|
||||||
status: item.status
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || String(a.name).localeCompare(String(b.name), "zh-Hans-CN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appOptionsFromMasterData(apps, fallbackOptions) {
|
|
||||||
const options = uniqueOptions(apps.map((app) => [app.appCode, app.appName || app.appCode]));
|
|
||||||
return options.length ? [["", "全部 App"], ...options] : fallbackOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countryOptionsFromMasterData(countries, fallbackCountries) {
|
|
||||||
const options = uniqueOptions(countries.map((country) => [country.countryCode, countryLabel(country)]));
|
|
||||||
return options.length ? [["", "全部国家"], ...options] : [["", "全部国家"], ...fallbackCountries.map((country) => [country.code, country.label])];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regionOptionsFromMasterData(regions) {
|
|
||||||
return [
|
|
||||||
["", "全部区域"],
|
|
||||||
...uniqueOptions(
|
|
||||||
regions.map((region) => [
|
|
||||||
String(region.regionId),
|
|
||||||
[region.name, region.regionCode, region.regionId].filter(Boolean).join(" · ")
|
|
||||||
])
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countryLabel(country) {
|
|
||||||
return [country.flag, country.countryDisplayName || country.countryName, country.countryCode].filter(Boolean).join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMoneyModules(scope, performanceItems = []) {
|
|
||||||
const apps = scope?.apps || [];
|
|
||||||
const regions = scope?.regions || [];
|
|
||||||
const countries = scope?.countries || [];
|
|
||||||
const performanceByRegion = new Map();
|
|
||||||
performanceItems.forEach((item) => {
|
|
||||||
const key = `${stringValue(item.appCode ?? item.app_code)}:${Number(item.regionId ?? item.region_id ?? 0)}:${Number(item.operatorUserId ?? item.operator_user_id ?? 0)}`;
|
|
||||||
performanceByRegion.set(key, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
const modules = [];
|
|
||||||
performanceByRegion.forEach((item) => {
|
|
||||||
modules.push(moduleFromPerformance(item, apps, regions, countries));
|
|
||||||
});
|
|
||||||
|
|
||||||
scope.scopes
|
|
||||||
?.filter((scopeItem) => Number(scopeItem.regionId) >= 0)
|
|
||||||
.forEach((scopeItem) => {
|
|
||||||
if (Number(scopeItem.regionId) === 0) {
|
|
||||||
const appRegions = regions.filter((region) => region.appCode === scopeItem.appCode);
|
|
||||||
if (appRegions.length) {
|
|
||||||
appRegions.forEach((region) => {
|
|
||||||
const hasRegionModule = modules.some(
|
|
||||||
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(region.regionId)
|
|
||||||
);
|
|
||||||
if (!hasRegionModule) {
|
|
||||||
modules.push(emptyModuleFromScope({ appCode: scopeItem.appCode, regionId: region.regionId }, apps, regions, countries));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const hasModule = modules.some(
|
|
||||||
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(scopeItem.regionId)
|
|
||||||
);
|
|
||||||
if (!hasModule) {
|
|
||||||
modules.push(emptyModuleFromScope(scopeItem, apps, regions, countries));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!modules.length && scope.all) {
|
|
||||||
regions.forEach((region) => modules.push(emptyModuleFromScope({ appCode: region.appCode, regionId: region.regionId }, apps, regions, countries)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return modules.sort((a, b) => b.rechargeUsd - a.rechargeUsd || a.moduleName.localeCompare(b.moduleName, "zh-Hans-CN"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleFromPerformance(item, apps, regions, countries) {
|
|
||||||
const appCode = stringValue(item.appCode ?? item.app_code);
|
|
||||||
const regionId = Number(item.regionId ?? item.region_id ?? 0);
|
|
||||||
const app = apps.find((nextApp) => nextApp.appCode === appCode) || {};
|
|
||||||
const region = regions.find((nextRegion) => nextRegion.appCode === appCode && Number(nextRegion.regionId) === regionId) || {};
|
|
||||||
const regionCountries = countries.filter((country) => country.appCode === appCode && Number(country.regionId) === regionId);
|
|
||||||
const paidUsd = Number(item.paidUsdMinor ?? item.paid_usd_minor ?? 0) / 100;
|
|
||||||
const paidLinkCount = Number(item.paidLinkCount ?? item.paid_link_count ?? 0);
|
|
||||||
const operatorId = String(item.operatorUserId ?? item.operator_user_id ?? "0");
|
|
||||||
const operatorName = stringValue(item.operatorName ?? item.operator_name ?? item.operatorAccount ?? item.operator_account) || "未归属";
|
|
||||||
return {
|
|
||||||
appCode,
|
|
||||||
appName: app.appName || appCode,
|
|
||||||
coinIssued: 0,
|
|
||||||
countryCode: regionCountries[0]?.countryCode || "ALL",
|
|
||||||
countryCodes: regionCountries.map((country) => country.countryCode),
|
|
||||||
countryName: regionCountries.length ? regionCountries.map(countryLabel).join(" / ") : "全部国家",
|
|
||||||
dau: 0,
|
|
||||||
dayTargetUsd: 0,
|
|
||||||
department: stringValue(item.operatorAccount ?? item.operator_account),
|
|
||||||
id: `${appCode}:${regionId}:${operatorId}`,
|
|
||||||
linkCount: paidLinkCount,
|
|
||||||
moduleName: `${app.appName || appCode} / ${region.name || (regionId === 0 ? "全部区域" : `区域 ${regionId}`)}`,
|
|
||||||
monthActualUsd: paidUsd,
|
|
||||||
monthTargetUsd: 0,
|
|
||||||
newUsers: 0,
|
|
||||||
operatorId,
|
|
||||||
operatorName,
|
|
||||||
paidLinkCount,
|
|
||||||
paidUsers: paidLinkCount,
|
|
||||||
rechargeTrend: [0, 0, 0, 0, 0, 0, paidUsd],
|
|
||||||
rechargeUsd: paidUsd,
|
|
||||||
region: [region.name, region.regionCode].filter(Boolean).join(" · ") || (regionId === 0 ? "全部区域" : String(regionId)),
|
|
||||||
regionId,
|
|
||||||
retention1: 0,
|
|
||||||
retention7: 0,
|
|
||||||
retention30: 0,
|
|
||||||
revenueTrend: [0, 0, 0, 0, 0, 0, paidUsd],
|
|
||||||
revenueUsd: paidUsd,
|
|
||||||
salaryUsd: 0,
|
|
||||||
scope: regionId === 0 ? "app" : "region"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyModuleFromScope(scopeItem, apps, regions, countries) {
|
|
||||||
return moduleFromPerformance(
|
|
||||||
{
|
|
||||||
appCode: scopeItem.appCode,
|
|
||||||
operatorName: "-",
|
|
||||||
paidLinkCount: 0,
|
|
||||||
paidUsdMinor: 0,
|
|
||||||
regionId: scopeItem.regionId
|
|
||||||
},
|
|
||||||
apps,
|
|
||||||
regions,
|
|
||||||
countries
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeScopes(scopes) {
|
|
||||||
return (Array.isArray(scopes) ? scopes : [])
|
|
||||||
.map((scope) => ({
|
|
||||||
appCode: stringValue(scope.appCode ?? scope.app_code),
|
|
||||||
regionId: numberValue(scope.regionId ?? scope.region_id)
|
|
||||||
}))
|
|
||||||
.filter((scope) => scope.appCode && scope.regionId >= 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyScope() {
|
|
||||||
return { all: false, apps: [], countries: [], regions: [], scopes: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function listItems(data) {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
if (Array.isArray(data?.items)) {
|
|
||||||
return data.items;
|
|
||||||
}
|
|
||||||
if (Array.isArray(data?.list)) {
|
|
||||||
return data.list;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCountryCodes(value) {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [...new Set(value.map((item) => stringValue(item).toUpperCase()).filter(Boolean))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueOptions(options) {
|
|
||||||
const seen = new Set();
|
|
||||||
return options.filter(([value]) => {
|
|
||||||
const key = String(value || "");
|
|
||||||
if (!key || seen.has(key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value) {
|
|
||||||
return String(value ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberValue(value) {
|
|
||||||
const nextValue = Number(value);
|
|
||||||
return Number.isFinite(nextValue) ? nextValue : 0;
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
countryOptionsFromMasterData,
|
|
||||||
fetchMoneyMasterData,
|
|
||||||
normalizeAdminApps,
|
|
||||||
normalizeCountries,
|
|
||||||
normalizeRegions,
|
|
||||||
regionOptionsFromMasterData
|
|
||||||
} from "./masterDataApi.js";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.localStorage.clear();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalizes app country and region master data for money filters", () => {
|
|
||||||
expect(normalizeAdminApps({ items: [{ app_code: "lalu", name: "Lalu" }] })).toEqual([
|
|
||||||
{ appCode: "lalu", appId: undefined, appName: "Lalu", packageName: "", platform: "", status: undefined }
|
|
||||||
]);
|
|
||||||
expect(normalizeCountries({ items: [{ country_code: "sa", country_display_name: "沙特", sort_order: 2 }] })).toEqual([
|
|
||||||
{
|
|
||||||
appCode: "",
|
|
||||||
countryCode: "SA",
|
|
||||||
countryDisplayName: "沙特",
|
|
||||||
countryId: undefined,
|
|
||||||
countryName: "",
|
|
||||||
enabled: undefined,
|
|
||||||
flag: "",
|
|
||||||
isoAlpha3: "",
|
|
||||||
isoNumeric: "",
|
|
||||||
phoneCountryCode: "",
|
|
||||||
regionId: 0,
|
|
||||||
sortOrder: 2
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
expect(normalizeRegions({ items: [{ countries: ["sa", "AE"], name: "中东", region_code: "me", region_id: 7 }] })).toEqual([
|
|
||||||
{
|
|
||||||
appCode: "",
|
|
||||||
countries: ["SA", "AE"],
|
|
||||||
name: "中东",
|
|
||||||
regionCode: "ME",
|
|
||||||
regionId: 7,
|
|
||||||
sortOrder: 0,
|
|
||||||
status: undefined
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("builds select options from real money master data", () => {
|
|
||||||
const countries = normalizeCountries({ items: [{ countryCode: "BR", countryDisplayName: "巴西" }] });
|
|
||||||
const regions = normalizeRegions({ items: [{ countries: ["BR"], name: "拉美", regionCode: "LATAM", regionId: 3 }] });
|
|
||||||
|
|
||||||
expect(countryOptionsFromMasterData(countries, [])).toEqual([
|
|
||||||
["", "全部国家"],
|
|
||||||
["BR", "巴西 · BR"]
|
|
||||||
]);
|
|
||||||
expect(regionOptionsFromMasterData(regions)).toEqual([
|
|
||||||
["", "全部区域"],
|
|
||||||
["3", "拉美 · LATAM · 3"]
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetches money master data from real admin API paths", async () => {
|
|
||||||
const calls = [];
|
|
||||||
window.localStorage.setItem("hyapp-admin.access-token", "token-1");
|
|
||||||
window.localStorage.setItem("hyapp-admin.app-code", "yumi");
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(async (input, init) => {
|
|
||||||
calls.push({ init, url: String(input) });
|
|
||||||
const url = String(input);
|
|
||||||
if (url.includes("/v1/admin/money/scope")) {
|
|
||||||
return jsonResponse({
|
|
||||||
all: false,
|
|
||||||
apps: [{ appCode: "lalu", appName: "Lalu" }],
|
|
||||||
countries: [{ appCode: "lalu", countryCode: "SA", countryDisplayName: "沙特", regionId: 2 }],
|
|
||||||
regions: [{ appCode: "lalu", countries: ["SA"], name: "中东", regionCode: "ME", regionId: 2 }],
|
|
||||||
scopes: [{ appCode: "lalu", regionId: 2 }]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("/v1/admin/money/performance")) {
|
|
||||||
return jsonResponse({
|
|
||||||
items: [{ appCode: "lalu", operatorName: "Admin", operatorUserId: 1, paidLinkCount: 1, paidUsdMinor: 1299, regionId: 2 }],
|
|
||||||
summary: { paidLinkCount: 1, paidUsdMinor: 1299 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jsonResponse(null, 404);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await fetchMoneyMasterData();
|
|
||||||
|
|
||||||
expect(data.error).toBe("");
|
|
||||||
expect(data.apps.map((app) => app.appCode)).toEqual(["lalu"]);
|
|
||||||
expect(data.countries.map((country) => country.countryCode)).toEqual(["SA"]);
|
|
||||||
expect(data.regions.map((region) => region.regionId)).toEqual([2]);
|
|
||||||
expect(data.modules).toHaveLength(1);
|
|
||||||
expect(data.modules[0].rechargeUsd).toBe(12.99);
|
|
||||||
expect(calls.map((call) => new URL(call.url).pathname).sort()).toEqual([
|
|
||||||
"/api/v1/admin/money/performance",
|
|
||||||
"/api/v1/admin/money/scope"
|
|
||||||
]);
|
|
||||||
expect(calls[0].init.headers.Authorization).toBe("Bearer token-1");
|
|
||||||
expect(calls[0].init.headers["X-App-Code"]).toBe("yumi");
|
|
||||||
});
|
|
||||||
|
|
||||||
function jsonResponse(data, status = 200) {
|
|
||||||
return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), {
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
status
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,424 +0,0 @@
|
|||||||
export const moneyOperators = [
|
|
||||||
{ id: "mina-chen", label: "Mina Chen", department: "平台运营组" },
|
|
||||||
{ id: "sarah-ali", label: "Sarah Ali", department: "中东运营组" },
|
|
||||||
{ id: "leo-wang", label: "Leo Wang", department: "拉美拓展组" },
|
|
||||||
{ id: "diego-costa", label: "Diego Costa", department: "直播增长组" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const moneyCountries = [
|
|
||||||
{ code: "AE", currency: "AED", label: "阿联酋", region: "MENA" },
|
|
||||||
{ code: "SA", currency: "SAR", label: "沙特", region: "MENA" },
|
|
||||||
{ code: "BR", currency: "BRL", label: "巴西", region: "LATAM" },
|
|
||||||
{ code: "MX", currency: "MXN", label: "墨西哥", region: "LATAM" },
|
|
||||||
{ code: "ID", currency: "IDR", label: "印尼", region: "SEA" },
|
|
||||||
{ code: "TR", currency: "TRY", label: "土耳其", region: "EMEA" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const moneyAppOptions = [
|
|
||||||
["", "全部 App"],
|
|
||||||
["hy-live", "HY Live"],
|
|
||||||
["voice-party", "Voice Party"],
|
|
||||||
["social-pro", "Social Pro"],
|
|
||||||
["spark-chat", "Spark Chat"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const moneyProviderOptions = [
|
|
||||||
{ code: "mifapay", label: "MiFaPay", countries: ["AE", "SA", "TR"] },
|
|
||||||
{ code: "v5pay", label: "V5Pay", countries: ["BR", "MX", "ID"] },
|
|
||||||
{ code: "stripe", label: "Stripe", countries: ["AE", "SA", "BR", "MX", "ID", "TR"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const trendLabels = ["06/21", "06/22", "06/23", "06/24", "06/25", "06/26", "06/27"];
|
|
||||||
|
|
||||||
export const moneyModules = [
|
|
||||||
{
|
|
||||||
id: "hy-live-app",
|
|
||||||
appCode: "hy-live",
|
|
||||||
appName: "HY Live",
|
|
||||||
moduleName: "HY Live 全站",
|
|
||||||
scope: "app",
|
|
||||||
countryCode: "ALL",
|
|
||||||
countryName: "全部国家",
|
|
||||||
region: "Global",
|
|
||||||
operatorId: "mina-chen",
|
|
||||||
operatorName: "Mina Chen",
|
|
||||||
department: "平台运营组",
|
|
||||||
rechargeUsd: 148420,
|
|
||||||
revenueUsd: 316800,
|
|
||||||
salaryUsd: 64100,
|
|
||||||
paidUsers: 7620,
|
|
||||||
newUsers: 42850,
|
|
||||||
dau: 960400,
|
|
||||||
dayTargetUsd: 150000,
|
|
||||||
monthActualUsd: 3410000,
|
|
||||||
monthTargetUsd: 3800000,
|
|
||||||
retention1: 44.8,
|
|
||||||
retention7: 21.4,
|
|
||||||
retention30: 9.2,
|
|
||||||
linkCount: 46,
|
|
||||||
paidLinkCount: 31,
|
|
||||||
coinIssued: 15180000,
|
|
||||||
rechargeTrend: [112000, 124500, 118900, 133400, 141200, 139800, 148420],
|
|
||||||
revenueTrend: [246000, 258400, 249900, 281200, 297800, 301200, 316800],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "voice-party-ksa",
|
|
||||||
appCode: "voice-party",
|
|
||||||
appName: "Voice Party",
|
|
||||||
moduleName: "Voice Party / 沙特",
|
|
||||||
scope: "region",
|
|
||||||
countryCode: "SA",
|
|
||||||
countryName: "沙特",
|
|
||||||
region: "MENA-KSA",
|
|
||||||
operatorId: "sarah-ali",
|
|
||||||
operatorName: "Sarah Ali",
|
|
||||||
department: "中东运营组",
|
|
||||||
rechargeUsd: 76500,
|
|
||||||
revenueUsd: 164200,
|
|
||||||
salaryUsd: 29800,
|
|
||||||
paidUsers: 3090,
|
|
||||||
newUsers: 12800,
|
|
||||||
dau: 286400,
|
|
||||||
dayTargetUsd: 72000,
|
|
||||||
monthActualUsd: 1820000,
|
|
||||||
monthTargetUsd: 1750000,
|
|
||||||
retention1: 47.5,
|
|
||||||
retention7: 24.1,
|
|
||||||
retention30: 10.8,
|
|
||||||
linkCount: 29,
|
|
||||||
paidLinkCount: 22,
|
|
||||||
coinIssued: 7820000,
|
|
||||||
rechargeTrend: [54800, 61200, 59400, 68000, 70400, 73900, 76500],
|
|
||||||
revenueTrend: [126000, 132100, 129600, 145800, 151200, 158000, 164200],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "voice-party-uae",
|
|
||||||
appCode: "voice-party",
|
|
||||||
appName: "Voice Party",
|
|
||||||
moduleName: "Voice Party / 阿联酋",
|
|
||||||
scope: "region",
|
|
||||||
countryCode: "AE",
|
|
||||||
countryName: "阿联酋",
|
|
||||||
region: "MENA-UAE",
|
|
||||||
operatorId: "sarah-ali",
|
|
||||||
operatorName: "Sarah Ali",
|
|
||||||
department: "中东运营组",
|
|
||||||
rechargeUsd: 43800,
|
|
||||||
revenueUsd: 88200,
|
|
||||||
salaryUsd: 17200,
|
|
||||||
paidUsers: 2020,
|
|
||||||
newUsers: 7100,
|
|
||||||
dau: 155300,
|
|
||||||
dayTargetUsd: 52000,
|
|
||||||
monthActualUsd: 930000,
|
|
||||||
monthTargetUsd: 1200000,
|
|
||||||
retention1: 41.6,
|
|
||||||
retention7: 18.7,
|
|
||||||
retention30: 7.9,
|
|
||||||
linkCount: 18,
|
|
||||||
paidLinkCount: 10,
|
|
||||||
coinIssued: 3980000,
|
|
||||||
rechargeTrend: [39100, 40200, 38800, 42100, 43700, 43000, 43800],
|
|
||||||
revenueTrend: [76900, 80200, 78800, 83500, 86100, 87300, 88200],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "social-pro-brazil",
|
|
||||||
appCode: "social-pro",
|
|
||||||
appName: "Social Pro",
|
|
||||||
moduleName: "Social Pro / 巴西",
|
|
||||||
scope: "region",
|
|
||||||
countryCode: "BR",
|
|
||||||
countryName: "巴西",
|
|
||||||
region: "LATAM-BR",
|
|
||||||
operatorId: "leo-wang",
|
|
||||||
operatorName: "Leo Wang",
|
|
||||||
department: "拉美拓展组",
|
|
||||||
rechargeUsd: 58200,
|
|
||||||
revenueUsd: 126300,
|
|
||||||
salaryUsd: 22100,
|
|
||||||
paidUsers: 4200,
|
|
||||||
newUsers: 21100,
|
|
||||||
dau: 418700,
|
|
||||||
dayTargetUsd: 50000,
|
|
||||||
monthActualUsd: 1260000,
|
|
||||||
monthTargetUsd: 1080000,
|
|
||||||
retention1: 39.2,
|
|
||||||
retention7: 16.5,
|
|
||||||
retention30: 6.8,
|
|
||||||
linkCount: 34,
|
|
||||||
paidLinkCount: 26,
|
|
||||||
coinIssued: 6200000,
|
|
||||||
rechargeTrend: [42100, 43800, 47200, 50100, 52700, 54100, 58200],
|
|
||||||
revenueTrend: [89200, 93400, 102200, 109800, 115600, 118500, 126300],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "social-pro-mexico",
|
|
||||||
appCode: "social-pro",
|
|
||||||
appName: "Social Pro",
|
|
||||||
moduleName: "Social Pro / 墨西哥",
|
|
||||||
scope: "region",
|
|
||||||
countryCode: "MX",
|
|
||||||
countryName: "墨西哥",
|
|
||||||
region: "LATAM-MX",
|
|
||||||
operatorId: "leo-wang",
|
|
||||||
operatorName: "Leo Wang",
|
|
||||||
department: "拉美拓展组",
|
|
||||||
rechargeUsd: 31600,
|
|
||||||
revenueUsd: 70600,
|
|
||||||
salaryUsd: 14300,
|
|
||||||
paidUsers: 2380,
|
|
||||||
newUsers: 9400,
|
|
||||||
dau: 180200,
|
|
||||||
dayTargetUsd: 36000,
|
|
||||||
monthActualUsd: 720000,
|
|
||||||
monthTargetUsd: 820000,
|
|
||||||
retention1: 36.4,
|
|
||||||
retention7: 14.8,
|
|
||||||
retention30: 5.9,
|
|
||||||
linkCount: 15,
|
|
||||||
paidLinkCount: 9,
|
|
||||||
coinIssued: 3320000,
|
|
||||||
rechargeTrend: [27600, 28100, 29400, 30200, 31800, 30500, 31600],
|
|
||||||
revenueTrend: [60400, 62100, 64100, 66600, 68900, 68200, 70600],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spark-chat-indonesia",
|
|
||||||
appCode: "spark-chat",
|
|
||||||
appName: "Spark Chat",
|
|
||||||
moduleName: "Spark Chat / 印尼",
|
|
||||||
scope: "region",
|
|
||||||
countryCode: "ID",
|
|
||||||
countryName: "印尼",
|
|
||||||
region: "SEA-ID",
|
|
||||||
operatorId: "diego-costa",
|
|
||||||
operatorName: "Diego Costa",
|
|
||||||
department: "直播增长组",
|
|
||||||
rechargeUsd: 39800,
|
|
||||||
revenueUsd: 90200,
|
|
||||||
salaryUsd: 18600,
|
|
||||||
paidUsers: 3510,
|
|
||||||
newUsers: 18400,
|
|
||||||
dau: 322600,
|
|
||||||
dayTargetUsd: 42000,
|
|
||||||
monthActualUsd: 860000,
|
|
||||||
monthTargetUsd: 940000,
|
|
||||||
retention1: 43.1,
|
|
||||||
retention7: 19.3,
|
|
||||||
retention30: 8.1,
|
|
||||||
linkCount: 21,
|
|
||||||
paidLinkCount: 14,
|
|
||||||
coinIssued: 4460000,
|
|
||||||
rechargeTrend: [33800, 35600, 36100, 38200, 39100, 38800, 39800],
|
|
||||||
revenueTrend: [76400, 80100, 81200, 84900, 87600, 88400, 90200],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const initialPaymentLinks = [
|
|
||||||
createPaymentLinkRecord({
|
|
||||||
amountUsd: 299,
|
|
||||||
countryCode: "SA",
|
|
||||||
moduleId: "voice-party-ksa",
|
|
||||||
now: new Date("2026-06-27T08:32:00+08:00"),
|
|
||||||
operatorName: "Sarah Ali",
|
|
||||||
providerCode: "mifapay",
|
|
||||||
status: "paid",
|
|
||||||
}),
|
|
||||||
createPaymentLinkRecord({
|
|
||||||
amountUsd: 129,
|
|
||||||
countryCode: "BR",
|
|
||||||
moduleId: "social-pro-brazil",
|
|
||||||
now: new Date("2026-06-27T10:18:00+08:00"),
|
|
||||||
operatorName: "Leo Wang",
|
|
||||||
providerCode: "v5pay",
|
|
||||||
status: "redirected",
|
|
||||||
}),
|
|
||||||
createPaymentLinkRecord({
|
|
||||||
amountUsd: 499,
|
|
||||||
countryCode: "AE",
|
|
||||||
moduleId: "voice-party-uae",
|
|
||||||
now: new Date("2026-06-27T11:04:00+08:00"),
|
|
||||||
operatorName: "Sarah Ali",
|
|
||||||
providerCode: "mifapay",
|
|
||||||
status: "redirected",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
export function filterMoneyModules({
|
|
||||||
activeOperatorId = "",
|
|
||||||
appCode = "",
|
|
||||||
countryCode = "",
|
|
||||||
mode = "finance",
|
|
||||||
modules = moneyModules,
|
|
||||||
operatorKeyword = "",
|
|
||||||
regionId = "",
|
|
||||||
regions = [],
|
|
||||||
} = {}) {
|
|
||||||
const normalizedKeyword = operatorKeyword.trim().toLowerCase();
|
|
||||||
|
|
||||||
return modules.filter((module) => {
|
|
||||||
const matchesRole = mode !== "operator" || module.operatorId === activeOperatorId;
|
|
||||||
const matchesApp = !appCode || module.appCode === appCode;
|
|
||||||
const matchesCountry =
|
|
||||||
!countryCode || module.countryCode === countryCode || (module.scope === "app" && module.countryCode === "ALL");
|
|
||||||
const matchesRegion = !regionId || moneyModuleMatchesRegion(module, regionId, regions);
|
|
||||||
const matchesOperator =
|
|
||||||
!normalizedKeyword ||
|
|
||||||
module.operatorName.toLowerCase().includes(normalizedKeyword) ||
|
|
||||||
module.department.toLowerCase().includes(normalizedKeyword);
|
|
||||||
|
|
||||||
return matchesRole && matchesApp && matchesCountry && matchesRegion && matchesOperator;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moneyModuleMatchesRegion(module, regionId, regions = []) {
|
|
||||||
if (module.scope === "app" && module.countryCode === "ALL") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const normalizedRegionId = String(regionId || "");
|
|
||||||
if (!normalizedRegionId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ((module.regionId === 0 || module.regionId) && String(module.regionId) === normalizedRegionId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const region = regions.find((item) => String(item.regionId ?? item.value ?? item.id) === normalizedRegionId);
|
|
||||||
if (!region) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const countryCodes = new Set(
|
|
||||||
(region.countries || region.countryCodes || region.country_codes || [])
|
|
||||||
.map((countryCode) =>
|
|
||||||
String(countryCode || "")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase(),
|
|
||||||
)
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
return countryCodes.has(String(module.countryCode || "").toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeMoneyModules(modules = []) {
|
|
||||||
const rechargeUsd = sumBy(modules, "rechargeUsd");
|
|
||||||
const revenueUsd = sumBy(modules, "revenueUsd");
|
|
||||||
const salaryUsd = sumBy(modules, "salaryUsd");
|
|
||||||
const paidUsers = sumBy(modules, "paidUsers");
|
|
||||||
const newUsers = sumBy(modules, "newUsers");
|
|
||||||
const dau = sumBy(modules, "dau");
|
|
||||||
const dayTargetUsd = sumBy(modules, "dayTargetUsd");
|
|
||||||
const monthActualUsd = sumBy(modules, "monthActualUsd");
|
|
||||||
const monthTargetUsd = sumBy(modules, "monthTargetUsd");
|
|
||||||
|
|
||||||
return {
|
|
||||||
arppuUsd: paidUsers > 0 ? rechargeUsd / paidUsers : 0,
|
|
||||||
coinIssued: sumBy(modules, "coinIssued"),
|
|
||||||
dau,
|
|
||||||
dayProgress: percentage(rechargeUsd, dayTargetUsd),
|
|
||||||
dayTargetUsd,
|
|
||||||
grossProfitUsd: revenueUsd - salaryUsd,
|
|
||||||
linkConversion: percentage(sumBy(modules, "paidLinkCount"), sumBy(modules, "linkCount")),
|
|
||||||
monthActualUsd,
|
|
||||||
monthProgress: percentage(monthActualUsd, monthTargetUsd),
|
|
||||||
monthTargetUsd,
|
|
||||||
newUsers,
|
|
||||||
paidLinkCount: sumBy(modules, "paidLinkCount"),
|
|
||||||
paidUsers,
|
|
||||||
rechargeTrend: sumTrend(modules, "rechargeTrend"),
|
|
||||||
rechargeUsd,
|
|
||||||
retention1: averageBy(modules, "retention1"),
|
|
||||||
retention7: averageBy(modules, "retention7"),
|
|
||||||
retention30: averageBy(modules, "retention30"),
|
|
||||||
revenueTrend: sumTrend(modules, "revenueTrend"),
|
|
||||||
revenueUsd,
|
|
||||||
salaryUsd,
|
|
||||||
totalLinkCount: sumBy(modules, "linkCount"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPaymentLinkRecord({
|
|
||||||
amountUsd,
|
|
||||||
countryCode,
|
|
||||||
moduleId,
|
|
||||||
now = new Date(),
|
|
||||||
operatorName,
|
|
||||||
providerCode,
|
|
||||||
status = "redirected",
|
|
||||||
}) {
|
|
||||||
const module = moneyModules.find((item) => item.id === moduleId) || moneyModules[0];
|
|
||||||
const country = moneyCountries.find((item) => item.code === countryCode) || moneyCountries[0];
|
|
||||||
const provider = moneyProviderOptions.find((item) => item.code === providerCode) || moneyProviderOptions[0];
|
|
||||||
const createdAtMs = now.getTime();
|
|
||||||
const suffix = compactTimestamp(now);
|
|
||||||
const orderId = `MNY-${country.code}-${suffix}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
amountUsd: Number(amountUsd) || 0,
|
|
||||||
appName: module.appName,
|
|
||||||
countryCode: country.code,
|
|
||||||
countryName: country.label,
|
|
||||||
createdAtMs,
|
|
||||||
currency: country.currency,
|
|
||||||
id: orderId,
|
|
||||||
moduleId: module.id,
|
|
||||||
moduleName: module.moduleName,
|
|
||||||
operatorName: operatorName || module.operatorName,
|
|
||||||
payUrl: `https://pay.hyapp.example/${country.code.toLowerCase()}/${orderId.toLowerCase()}`,
|
|
||||||
providerCode: provider.code,
|
|
||||||
providerName: provider.label,
|
|
||||||
region: country.region,
|
|
||||||
status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function availableCountriesForModule(moduleId) {
|
|
||||||
const module = moneyModules.find((item) => item.id === moduleId);
|
|
||||||
if (!module || module.scope === "app" || module.countryCode === "ALL") {
|
|
||||||
return moneyCountries;
|
|
||||||
}
|
|
||||||
return moneyCountries.filter((country) => country.code === module.countryCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function availableProvidersForCountry(countryCode) {
|
|
||||||
return moneyProviderOptions.filter((provider) => provider.countries.includes(countryCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paymentStatusLabel(status) {
|
|
||||||
if (status === "paid") {
|
|
||||||
return "已支付";
|
|
||||||
}
|
|
||||||
if (status === "redirected") {
|
|
||||||
return "已生成";
|
|
||||||
}
|
|
||||||
if (status === "failed") {
|
|
||||||
return "失败";
|
|
||||||
}
|
|
||||||
return status || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumBy(items, key) {
|
|
||||||
return items.reduce((total, item) => total + Number(item[key] || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageBy(items, key) {
|
|
||||||
if (!items.length) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return sumBy(items, key) / items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumTrend(items, key) {
|
|
||||||
return trendLabels.map((_, index) => items.reduce((total, item) => total + Number(item[key]?.[index] || 0), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function percentage(value, total) {
|
|
||||||
if (!total) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (Number(value) / Number(total)) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactTimestamp(date) {
|
|
||||||
const pad = (value) => String(value).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(
|
|
||||||
date.getMinutes(),
|
|
||||||
)}${pad(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import {
|
|
||||||
availableProvidersForCountry,
|
|
||||||
createPaymentLinkRecord,
|
|
||||||
filterMoneyModules,
|
|
||||||
moneyModules,
|
|
||||||
summarizeMoneyModules,
|
|
||||||
} from "./data.js";
|
|
||||||
|
|
||||||
test("filters all modules for finance and owned modules for operators", () => {
|
|
||||||
expect(filterMoneyModules({ mode: "finance" })).toHaveLength(moneyModules.length);
|
|
||||||
expect(filterMoneyModules({ activeOperatorId: "sarah-ali", mode: "operator" }).map((module) => module.id)).toEqual([
|
|
||||||
"voice-party-ksa",
|
|
||||||
"voice-party-uae",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps app-level modules visible when filtering by country", () => {
|
|
||||||
const modules = filterMoneyModules({ countryCode: "BR", mode: "finance" });
|
|
||||||
|
|
||||||
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "social-pro-brazil"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("filters region modules from real region country mappings", () => {
|
|
||||||
const modules = filterMoneyModules({
|
|
||||||
mode: "finance",
|
|
||||||
regionId: "2",
|
|
||||||
regions: [{ countries: ["SA", "AE"], name: "中东", regionCode: "ME", regionId: 2 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "voice-party-ksa", "voice-party-uae"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("summarizes visible modules for KPI cards and progress bars", () => {
|
|
||||||
const summary = summarizeMoneyModules(filterMoneyModules({ activeOperatorId: "leo-wang", mode: "operator" }));
|
|
||||||
|
|
||||||
expect(summary.rechargeUsd).toBe(89800);
|
|
||||||
expect(summary.totalLinkCount).toBe(49);
|
|
||||||
expect(summary.monthProgress).toBeGreaterThan(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates country payment link records with available provider metadata", () => {
|
|
||||||
const link = createPaymentLinkRecord({
|
|
||||||
amountUsd: 129,
|
|
||||||
countryCode: "BR",
|
|
||||||
moduleId: "social-pro-brazil",
|
|
||||||
now: new Date("2026-06-27T00:00:00Z"),
|
|
||||||
providerCode: "v5pay",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(link.id).toMatch(/^MNY-BR-/);
|
|
||||||
expect(link.countryName).toBe("巴西");
|
|
||||||
expect(link.providerName).toBe("V5Pay");
|
|
||||||
expect(link.payUrl).toContain("/br/");
|
|
||||||
expect(availableProvidersForCountry("BR").map((provider) => provider.code)).toContain("v5pay");
|
|
||||||
});
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
export function formatUsd(value, options = {}) {
|
|
||||||
const digits = options.compact ? 1 : 2;
|
|
||||||
return `US$ ${Number(value || 0).toLocaleString("zh-CN", {
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumber(value) {
|
|
||||||
return Number(value || 0).toLocaleString("zh-CN");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPercent(value, digits = 1) {
|
|
||||||
return `${Number(value || 0).toLocaleString("zh-CN", {
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
})}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
|
||||||
if (!value) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const pad = (part) => String(part).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
|
||||||
date.getMinutes(),
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
|
||||||
import { adminApiGet, adminApiRequest } from "./api.js";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
export function listAdminTemporaryPaymentLinks({ appCode = "", keyword = "", operatorUserId = "", providerCode = "", regionId = "", status = "" } = {}) {
|
|
||||||
return adminApiGet(apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks), {
|
|
||||||
query: {
|
|
||||||
app_code: appCode,
|
|
||||||
keyword,
|
|
||||||
operator_user_id: operatorUserId,
|
|
||||||
page: 1,
|
|
||||||
page_size: PAGE_SIZE,
|
|
||||||
provider_code: providerCode,
|
|
||||||
region_id: regionId,
|
|
||||||
status
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminTemporaryPaymentLink(orderId) {
|
|
||||||
return adminApiGet(apiEndpointPath(API_OPERATIONS.getTemporaryPaymentLink, { order_id: orderId }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminTemporaryPaymentLink(payload) {
|
|
||||||
return adminApiRequest(apiEndpointPath(API_OPERATIONS.createTemporaryPaymentLink), {
|
|
||||||
body: payload,
|
|
||||||
method: "POST"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAdminPaymentMethods(appCode) {
|
|
||||||
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels), {
|
|
||||||
headers: appCode ? { "X-App-Code": appCode } : undefined
|
|
||||||
});
|
|
||||||
const channels = Array.isArray(data?.items) ? data.items : [];
|
|
||||||
return channels.flatMap((channel) =>
|
|
||||||
(Array.isArray(channel.methods) ? channel.methods : []).map((method) => ({
|
|
||||||
...method,
|
|
||||||
app_code: method.appCode ?? method.app_code ?? channel.appCode ?? channel.app_code,
|
|
||||||
country_code: method.countryCode ?? method.country_code,
|
|
||||||
country_name: method.countryName ?? method.country_name,
|
|
||||||
currency_code: method.currencyCode ?? method.currency_code,
|
|
||||||
logo_url: method.logoUrl ?? method.logo_url,
|
|
||||||
method_id: method.methodId ?? method.method_id,
|
|
||||||
method_name: method.methodName ?? method.method_name,
|
|
||||||
pay_type: method.payType ?? method.pay_type,
|
|
||||||
pay_way: method.payWay ?? method.pay_way,
|
|
||||||
provider_code: method.providerCode ?? method.provider_code ?? channel.providerCode ?? channel.provider_code,
|
|
||||||
provider_name: method.providerName ?? method.provider_name ?? channel.providerName ?? channel.provider_name,
|
|
||||||
sort_order: method.sortOrder ?? method.sort_order,
|
|
||||||
status: method.status
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,117 +0,0 @@
|
|||||||
const RETURN_URL_BASE = "https://h5.global-interaction.com/recharge/index.html";
|
|
||||||
|
|
||||||
export const TEMPORARY_RETURN_APPS = ["lalu", "yumi", "aslan"];
|
|
||||||
|
|
||||||
export function defaultReturnUrl(appCode = "lalu") {
|
|
||||||
const url = new URL(RETURN_URL_BASE);
|
|
||||||
url.searchParams.set("app_code", String(appCode || "lalu").trim() || "lalu");
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isKnownDefaultReturnUrl(value) {
|
|
||||||
const text = String(value || "").trim();
|
|
||||||
return TEMPORARY_RETURN_APPS.some((appCode) => text === defaultReturnUrl(appCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dollarsToMinor(value) {
|
|
||||||
const text = String(value || "").trim();
|
|
||||||
if (!/^\d+(\.\d{1,2})?$/.test(text)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.round(Number(text) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractOrderId(value) {
|
|
||||||
const text = String(value || "").trim();
|
|
||||||
if (!text) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(text, window.location.href);
|
|
||||||
const queryId = url.searchParams.get("order_id") || url.searchParams.get("orderId");
|
|
||||||
if (queryId) {
|
|
||||||
return queryId.trim();
|
|
||||||
}
|
|
||||||
const match = url.pathname.match(/temporary-pay\/([^/]+)\.html$/);
|
|
||||||
if (match) {
|
|
||||||
return decodeURIComponent(match[1]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Plain order ids are valid input.
|
|
||||||
}
|
|
||||||
return text.replace(/^order_id=/, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMinorUsd(value) {
|
|
||||||
if (!(value === 0 || value)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return `USD ${(Number(value) / 100).toLocaleString("zh-CN", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTemporaryCountries(sourceMethods) {
|
|
||||||
const byCode = {};
|
|
||||||
sourceMethods.forEach((method, index) => {
|
|
||||||
const code = temporaryCountryCode(method);
|
|
||||||
if (!code) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!byCode[code]) {
|
|
||||||
byCode[code] = {
|
|
||||||
code,
|
|
||||||
count: 0,
|
|
||||||
currency: method.currency_code || "",
|
|
||||||
firstSort: Number(method.sort_order || 0) || index,
|
|
||||||
name: method.country_name || code
|
|
||||||
};
|
|
||||||
}
|
|
||||||
byCode[code].count += 1;
|
|
||||||
});
|
|
||||||
return Object.keys(byCode)
|
|
||||||
.map((code) => byCode[code])
|
|
||||||
.sort((a, b) => a.firstSort - b.firstSort);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function temporaryCountryCode(method) {
|
|
||||||
return String(method?.country_code || "")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function temporaryCountryLabel(country) {
|
|
||||||
if (!country) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return country.name && country.name !== country.code ? `${country.name} · ${country.code}` : country.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function temporaryMethodLabel(method) {
|
|
||||||
return [method?.method_name, method?.pay_way, method?.pay_type, method?.provider_name].filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function temporaryLogoText(method) {
|
|
||||||
const value = method?.method_name || method?.pay_type || method?.provider_name || method?.provider_code || "Pay";
|
|
||||||
return String(value).replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function temporaryStatusLabel(status) {
|
|
||||||
if (status === "paid") {
|
|
||||||
return "已支付";
|
|
||||||
}
|
|
||||||
if (status === "redirected") {
|
|
||||||
return "已生成";
|
|
||||||
}
|
|
||||||
if (status === "pending") {
|
|
||||||
return "待支付";
|
|
||||||
}
|
|
||||||
if (status === "failed") {
|
|
||||||
return "失败";
|
|
||||||
}
|
|
||||||
if (status === "credited") {
|
|
||||||
return "已入账";
|
|
||||||
}
|
|
||||||
return status || "-";
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import { buildTemporaryCountries, defaultReturnUrl, dollarsToMinor, extractOrderId, temporaryStatusLabel } from "./temporaryPaymentApi.js";
|
|
||||||
|
|
||||||
test("converts usd text to minor amount with strict two-decimal validation", () => {
|
|
||||||
expect(dollarsToMinor("300")).toBe(30000);
|
|
||||||
expect(dollarsToMinor("9.99")).toBe(999);
|
|
||||||
expect(dollarsToMinor("9.999")).toBe(0);
|
|
||||||
expect(dollarsToMinor("abc")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts temporary order id from plain text, query, and wrapped html url", () => {
|
|
||||||
expect(extractOrderId("ORDER-1")).toBe("ORDER-1");
|
|
||||||
expect(extractOrderId("order_id=ORDER-2")).toBe("ORDER-2");
|
|
||||||
expect(extractOrderId("https://pay.example/pay?order_id=ORDER-3")).toBe("ORDER-3");
|
|
||||||
expect(extractOrderId("https://cos.example/temporary-pay/ORDER-4.html")).toBe("ORDER-4");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("builds country rows from temporary payment methods by first sort order", () => {
|
|
||||||
const countries = buildTemporaryCountries([
|
|
||||||
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 2, sort_order: 20 },
|
|
||||||
{ country_code: "SA", country_name: "Saudi", currency_code: "SAR", method_id: 1, sort_order: 10 },
|
|
||||||
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 3, sort_order: 30 }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(countries).toEqual([
|
|
||||||
{ code: "SA", count: 1, currency: "SAR", firstSort: 10, name: "Saudi" },
|
|
||||||
{ code: "BR", count: 2, currency: "BRL", firstSort: 20, name: "Brazil" }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps h5 return app separate from payment app code", () => {
|
|
||||||
expect(defaultReturnUrl("yumi")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=yumi");
|
|
||||||
expect(defaultReturnUrl("unknown")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=unknown");
|
|
||||||
expect(temporaryStatusLabel("credited")).toBe("已入账");
|
|
||||||
});
|
|
||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@ -1,15 +1,107 @@
|
|||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import styles from "./ErrorBoundary.module.css";
|
import styles from "./ErrorBoundary.module.css";
|
||||||
|
|
||||||
export class ErrorBoundary extends Component {
|
const ASSET_RELOAD_STORAGE_PREFIX = "hyapp-admin:asset-reload:";
|
||||||
state = { error: null };
|
const ASSET_RELOAD_RETRY_MS = 5 * 60 * 1000;
|
||||||
|
const ASSET_ERROR_PATTERNS = [
|
||||||
|
/Unable to preload CSS/i,
|
||||||
|
/Failed to fetch dynamically imported module/i,
|
||||||
|
/error loading dynamically imported module/i,
|
||||||
|
/Importing a module script failed/i,
|
||||||
|
/Load failed for the module/i,
|
||||||
|
/ChunkLoadError/i,
|
||||||
|
/CSS_CHUNK_LOAD_FAILED/i,
|
||||||
|
];
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
export function isRecoverableAssetLoadError(error) {
|
||||||
return { error };
|
const message = errorMessage(error);
|
||||||
|
return ASSET_ERROR_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetReloadStorageKey(error, location = window.location) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
const assetMatch = message.match(/\/assets\/[^\s"')]+/);
|
||||||
|
const asset = assetMatch?.[0] || message.slice(0, 160) || "unknown";
|
||||||
|
return `${ASSET_RELOAD_STORAGE_PREFIX}${location.pathname}:${asset}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error) {
|
||||||
|
if (!error) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [error.name, error.message, error.stack].filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryAssetReload(error) {
|
||||||
|
const key = assetReloadStorageKey(error);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previous = Number(window.sessionStorage.getItem(key) || "0");
|
||||||
|
if (previous && now - previous < ASSET_RELOAD_RETRY_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(key, String(now));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (window.__hyappAssetReloadAttempted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
window.__hyappAssetReloadAttempted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAssetReload(error) {
|
||||||
|
if (!isRecoverableAssetLoadError(error) || !canRetryAssetReload(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 50);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component {
|
||||||
|
state = { error: null, recovering: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error, recovering: isRecoverableAssetLoadError(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener("vite:preloadError", this.handlePreloadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener("vite:preloadError", this.handlePreloadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error) {
|
||||||
|
if (!scheduleAssetReload(error) && isRecoverableAssetLoadError(error)) {
|
||||||
|
this.setState({ recovering: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePreloadError = (event) => {
|
||||||
|
const error = event.payload || event.reason || event.error;
|
||||||
|
if (!scheduleAssetReload(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState({ error, recovering: true });
|
||||||
|
};
|
||||||
|
|
||||||
reset = () => {
|
reset = () => {
|
||||||
this.setState({ error: null });
|
this.setState({ error: null, recovering: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
reload = () => {
|
reload = () => {
|
||||||
@ -21,6 +113,17 @@ export class ErrorBoundary extends Component {
|
|||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.recovering) {
|
||||||
|
return (
|
||||||
|
<main className={styles.root}>
|
||||||
|
<section className={styles.panel}>
|
||||||
|
<h1 className={styles.title}>正在刷新页面</h1>
|
||||||
|
<p className={styles.message}>正在加载最新版本</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.root}>
|
<main className={styles.root}>
|
||||||
<section className={styles.panel}>
|
<section className={styles.panel}>
|
||||||
|
|||||||
21
src/app/ErrorBoundary.test.jsx
Normal file
21
src/app/ErrorBoundary.test.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { assetReloadStorageKey, isRecoverableAssetLoadError } from "./ErrorBoundary.jsx";
|
||||||
|
|
||||||
|
test("detects Vite CSS preload failures as recoverable asset errors", () => {
|
||||||
|
expect(isRecoverableAssetLoadError(new Error("Unable to preload CSS for /assets/HostOrgTable-DWK6sgCN.css"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isRecoverableAssetLoadError(new Error("Failed to fetch dynamically imported module"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps ordinary runtime errors visible", () => {
|
||||||
|
expect(isRecoverableAssetLoadError(new Error("Cannot read properties of undefined"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scopes asset reload attempts by route and asset", () => {
|
||||||
|
const key = assetReloadStorageKey(new Error("Unable to preload CSS for /assets/HostOrgTable-DWK6sgCN.css"), {
|
||||||
|
pathname: "/host/managers",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(key).toContain("/host/managers:/assets/HostOrgTable-DWK6sgCN.css");
|
||||||
|
});
|
||||||
@ -32,7 +32,7 @@ export function AdminLayout() {
|
|||||||
if (!backendMenus?.length) {
|
if (!backendMenus?.length) {
|
||||||
return fallbackMenus;
|
return fallbackMenus;
|
||||||
}
|
}
|
||||||
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
||||||
}, [backendMenus, can]);
|
}, [backendMenus, can]);
|
||||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ const deprecatedMenuCodes = new Set(["services"]);
|
|||||||
const menuLabelOverrides = {
|
const menuLabelOverrides = {
|
||||||
system: "后台设置",
|
system: "后台设置",
|
||||||
"system-users": "后台用户",
|
"system-users": "后台用户",
|
||||||
|
"system-teams": "团队配置",
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@ -133,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-withdrawals", { icon: ReceiptLongOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -261,6 +263,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
|
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
|
||||||
routeNavItem("logs-login", { icon: LoginOutlined }),
|
routeNavItem("logs-login", { icon: LoginOutlined }),
|
||||||
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
||||||
|
routeNavItem("system-teams", { icon: GroupsOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -340,9 +343,10 @@ export function filterNavigationByPermission(items = [], can) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
export function mergeNavigationItems(primaryItems = [], fallbackItems = [], orderItems = fallbackNavigation) {
|
||||||
const merged = primaryItems.map((item) => ({ ...item }));
|
const merged = primaryItems.map((item) => ({ ...item }));
|
||||||
const itemByCode = new Map(merged.map((item) => [item.code, item]));
|
const itemByCode = new Map(merged.map((item) => [item.code, item]));
|
||||||
|
const orderItemByCode = new Map(orderItems.map((item) => [item.code, item]));
|
||||||
|
|
||||||
fallbackItems.forEach((fallbackItem) => {
|
fallbackItems.forEach((fallbackItem) => {
|
||||||
const existing = itemByCode.get(fallbackItem.code);
|
const existing = itemByCode.get(fallbackItem.code);
|
||||||
@ -352,11 +356,15 @@ export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackItem.children?.length) {
|
if (fallbackItem.children?.length) {
|
||||||
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
|
existing.children = mergeNavigationItems(
|
||||||
|
existing.children || [],
|
||||||
|
fallbackItem.children,
|
||||||
|
orderItemByCode.get(fallbackItem.code)?.children || fallbackItem.children,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return orderNavigationItemsByFallback(merged, fallbackItems);
|
return orderNavigationItemsByFallback(merged, orderItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderNavigationItemsByFallback(items = [], fallbackItems = []) {
|
function orderNavigationItemsByFallback(items = [], fallbackItems = []) {
|
||||||
|
|||||||
@ -175,6 +175,37 @@ describe("navigation menu helpers", () => {
|
|||||||
"app-config-versions",
|
"app-config-versions",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("adds team settings under system menu when backend menu is stale", () => {
|
||||||
|
const backendMenus = [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{ code: "system-users", id: "system-users", label: "后台用户", path: "/system/users" },
|
||||||
|
{ code: "system-roles", id: "system-roles", label: "角色配置", path: "/system/roles" },
|
||||||
|
{ code: "system-menus", id: "system-menus", label: "菜单权限", path: "/system/menus" },
|
||||||
|
{ code: "logs-login", id: "logs-login", label: "登录日志", path: "/logs/login" },
|
||||||
|
{ code: "logs-operations", id: "logs-operations", label: "操作日志", path: "/logs/operations" },
|
||||||
|
],
|
||||||
|
code: "system",
|
||||||
|
icon: "settings",
|
||||||
|
id: "system",
|
||||||
|
label: "系统设置",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "team:view");
|
||||||
|
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
||||||
|
const system = merged.find((item) => item.code === "system");
|
||||||
|
|
||||||
|
expect(system.children.map((item) => item.code)).toEqual([
|
||||||
|
"system-users",
|
||||||
|
"system-roles",
|
||||||
|
"system-menus",
|
||||||
|
"logs-login",
|
||||||
|
"logs-operations",
|
||||||
|
"system-teams",
|
||||||
|
]);
|
||||||
|
expect(system.children.find((item) => item.code === "system-teams").path).toBe("/system/teams");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function flattenCodes(items) {
|
function flattenCodes(items) {
|
||||||
|
|||||||
@ -6,6 +6,9 @@ export const PERMISSIONS = {
|
|||||||
userStatus: "user:status",
|
userStatus: "user:status",
|
||||||
userResetPassword: "user:reset-password",
|
userResetPassword: "user:reset-password",
|
||||||
userExport: "user:export",
|
userExport: "user:export",
|
||||||
|
teamView: "team:view",
|
||||||
|
teamCreate: "team:create",
|
||||||
|
teamUpdate: "team:update",
|
||||||
countryView: "country:view",
|
countryView: "country:view",
|
||||||
countryCreate: "country:create",
|
countryCreate: "country:create",
|
||||||
countryUpdate: "country:update",
|
countryUpdate: "country:update",
|
||||||
@ -38,8 +41,18 @@ 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",
|
||||||
moneyView: "money:view",
|
hostWithdrawalView: "host-withdrawal:view",
|
||||||
moneyPaymentLinkCreate: "money:payment-link:create",
|
financeView: "finance:view",
|
||||||
|
financeApplicationCreate: "finance-application:create",
|
||||||
|
financeApplicationAudit: "finance-application:audit",
|
||||||
|
financeWithdrawalView: "finance-withdrawal:view",
|
||||||
|
financeWithdrawalAudit: "finance-withdrawal:audit",
|
||||||
|
financeOperationUserCoinCredit: "finance-operation:user-coin-credit",
|
||||||
|
financeOperationUserCoinDebit: "finance-operation:user-coin-debit",
|
||||||
|
financeOperationUserWalletCredit: "finance-operation:user-wallet-credit",
|
||||||
|
financeOperationUserWalletDebit: "finance-operation:user-wallet-debit",
|
||||||
|
financeOperationCoinSellerCoinCredit: "finance-operation:coin-seller-coin-credit",
|
||||||
|
financeOperationCoinSellerCoinDebit: "finance-operation:coin-seller-coin-debit",
|
||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||||
coinAdjustmentView: "coin-adjustment:view",
|
coinAdjustmentView: "coin-adjustment:view",
|
||||||
@ -55,6 +68,7 @@ export const PERMISSIONS = {
|
|||||||
paymentProductUpdate: "payment-product:update",
|
paymentProductUpdate: "payment-product:update",
|
||||||
paymentProductDelete: "payment-product:delete",
|
paymentProductDelete: "payment-product:delete",
|
||||||
paymentTemporaryLinkView: "payment-temporary-link:view",
|
paymentTemporaryLinkView: "payment-temporary-link:view",
|
||||||
|
paymentTemporaryLinkCreate: "payment-temporary-link:create",
|
||||||
paymentThirdPartyView: "payment-third-party:view",
|
paymentThirdPartyView: "payment-third-party:view",
|
||||||
paymentThirdPartyUpdate: "payment-third-party:update",
|
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||||
gameView: "game:view",
|
gameView: "game:view",
|
||||||
@ -187,6 +201,7 @@ export const MENU_CODES = {
|
|||||||
overview: "overview",
|
overview: "overview",
|
||||||
system: "system",
|
system: "system",
|
||||||
systemUsers: "system-users",
|
systemUsers: "system-users",
|
||||||
|
systemTeams: "system-teams",
|
||||||
systemRoles: "system-roles",
|
systemRoles: "system-roles",
|
||||||
systemMenus: "system-menus",
|
systemMenus: "system-menus",
|
||||||
logs: "logs",
|
logs: "logs",
|
||||||
@ -258,6 +273,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",
|
||||||
|
hostWithdrawals: "host-withdrawals",
|
||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
paymentRechargeProducts: "payment-recharge-products",
|
paymentRechargeProducts: "payment-recharge-products",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { roomRoutes } from "@/features/rooms/routes.js";
|
|||||||
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
||||||
import { roomTurnoverRewardRoutes } from "@/features/room-turnover-reward/routes.js";
|
import { roomTurnoverRewardRoutes } from "@/features/room-turnover-reward/routes.js";
|
||||||
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
||||||
|
import { teamsRoutes } from "@/features/teams/routes.js";
|
||||||
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
|
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
|
||||||
import { usersRoutes } from "@/features/users/routes.js";
|
import { usersRoutes } from "@/features/users/routes.js";
|
||||||
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
||||||
@ -71,6 +72,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...hostAgencyPolicyRoutes,
|
...hostAgencyPolicyRoutes,
|
||||||
...hostOrgRoutes,
|
...hostOrgRoutes,
|
||||||
...rolesRoutes,
|
...rolesRoutes,
|
||||||
|
...teamsRoutes,
|
||||||
...menusRoutes,
|
...menusRoutes,
|
||||||
...logsRoutes,
|
...logsRoutes,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
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 } from "./api";
|
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
@ -122,6 +122,76 @@ test("listPrettyDisplayIDs normalizes assigned user and operator snapshots", asy
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recyclePrettyDisplayID uses generated recycle endpoint", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
assigned_user_id: "",
|
||||||
|
display_user_id: "777777",
|
||||||
|
pretty_id: "pretty-2",
|
||||||
|
released_at_ms: 1782493200000,
|
||||||
|
source: "admin_grant",
|
||||||
|
status: "released",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await recyclePrettyDisplayID("pretty-2");
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/users/pretty-ids/pretty-2/recycle");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
assignedUserId: "",
|
||||||
|
displayUserId: "777777",
|
||||||
|
prettyId: "pretty-2",
|
||||||
|
status: "released",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listPrettyDisplayIDs normalizes zero assigned user as empty", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
assigned_user_id: "0",
|
||||||
|
display_user_id: "00002",
|
||||||
|
pretty_id: "pretty-0",
|
||||||
|
source: "pool",
|
||||||
|
status: "available",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 });
|
||||||
|
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
assignedUser: undefined,
|
||||||
|
assignedUserId: "",
|
||||||
|
displayUserId: "00002",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("listAppUsers sends country region and time filters", async () => {
|
test("listAppUsers sends country region and time filters", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
PrettyDisplayIDGenerationBatchDto,
|
PrettyDisplayIDGenerationBatchDto,
|
||||||
PrettyDisplayIDPoolDto,
|
PrettyDisplayIDPoolDto,
|
||||||
PrettyDisplayIDPoolPayload,
|
PrettyDisplayIDPoolPayload,
|
||||||
|
RecyclePrettyDisplayIDPayload,
|
||||||
SetPrettyDisplayIDStatusPayload,
|
SetPrettyDisplayIDStatusPayload,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
@ -180,6 +181,21 @@ export async function grantPrettyDisplayID(
|
|||||||
return normalizeAdminGrantPrettyDisplayIDResult(data);
|
return normalizeAdminGrantPrettyDisplayIDResult(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recyclePrettyDisplayID(
|
||||||
|
prettyId: EntityId,
|
||||||
|
payload: RecyclePrettyDisplayIDPayload = {},
|
||||||
|
): Promise<PrettyDisplayIDDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.recyclePrettyId;
|
||||||
|
const data = await apiRequest<RawPrettyDisplayID, RecyclePrettyDisplayIDPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.recyclePrettyId, { pretty_id: prettyId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return normalizePrettyDisplayID(data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setPrettyDisplayIDStatus(
|
export async function setPrettyDisplayIDStatus(
|
||||||
prettyId: EntityId,
|
prettyId: EntityId,
|
||||||
payload: SetPrettyDisplayIDStatusPayload,
|
payload: SetPrettyDisplayIDStatusPayload,
|
||||||
@ -614,7 +630,7 @@ function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const user = normalizeAppUserBrief(item);
|
const user = normalizeAppUserBrief(item);
|
||||||
return user.userId || user.displayUserId || user.username ? user : undefined;
|
return nonZeroStringValue(user.userId) || nonZeroStringValue(user.displayUserId) || user.username ? user : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
|
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
|
||||||
@ -692,7 +708,7 @@ function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto
|
|||||||
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
|
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
|
||||||
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
|
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
|
||||||
assignedUser: normalizeOptionalAppUserBrief(item.assignedUser ?? item.assigned_user),
|
assignedUser: normalizeOptionalAppUserBrief(item.assignedUser ?? item.assigned_user),
|
||||||
assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "",
|
assignedUserId: optionalEntityID(item.assignedUserId ?? item.assigned_user_id),
|
||||||
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
|
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
|
||||||
createdByAdminId,
|
createdByAdminId,
|
||||||
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
|
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
|
||||||
@ -748,3 +764,12 @@ function normalizeAdminGrantPrettyDisplayIDResult(
|
|||||||
prettyId: item.prettyId ?? item.pretty_id ?? "",
|
prettyId: item.prettyId ?? item.pretty_id ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalEntityID(value: unknown): string {
|
||||||
|
return nonZeroStringValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonZeroStringValue(value: unknown): string {
|
||||||
|
const normalized = stringValue(value);
|
||||||
|
return normalized === "0" ? "" : normalized;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
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 { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import {
|
import {
|
||||||
createPrettyDisplayIDPool,
|
createPrettyDisplayIDPool,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
grantPrettyDisplayID,
|
grantPrettyDisplayID,
|
||||||
listPrettyDisplayIDPools,
|
listPrettyDisplayIDPools,
|
||||||
listPrettyDisplayIDs,
|
listPrettyDisplayIDs,
|
||||||
|
recyclePrettyDisplayID,
|
||||||
setPrettyDisplayIDStatus,
|
setPrettyDisplayIDStatus,
|
||||||
updatePrettyDisplayIDPool,
|
updatePrettyDisplayIDPool,
|
||||||
} from "@/features/app-users/api";
|
} from "@/features/app-users/api";
|
||||||
@ -36,6 +38,7 @@ const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" }
|
|||||||
|
|
||||||
export function usePrettyIdsPage() {
|
export function usePrettyIdsPage() {
|
||||||
const abilities = useAppUserAbilities();
|
const abilities = useAppUserAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [poolPage, setPoolPage] = useState(1);
|
const [poolPage, setPoolPage] = useState(1);
|
||||||
const [poolStatus, setPoolStatus] = useState("");
|
const [poolStatus, setPoolStatus] = useState("");
|
||||||
@ -230,6 +233,26 @@ export function usePrettyIdsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recyclePrettyID = async (item) => {
|
||||||
|
if (!abilities.canPrettyUpdate || !canRecyclePrettyID(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await confirm({
|
||||||
|
title: "回收靓号",
|
||||||
|
message: `确认回收靓号 ${item.displayUserId || item.prettyId}?回收后用户将恢复默认短号。`,
|
||||||
|
confirmText: "回收",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`recycle-${item.prettyId}`, "靓号已回收", async () => {
|
||||||
|
// 回收只交给后台管理回收接口处理,前端不直接改 assigned/released 字段,避免和来源回收规则分叉。
|
||||||
|
await recyclePrettyDisplayID(item.prettyId);
|
||||||
|
await idQuery.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const changePoolStatus = (value) => {
|
const changePoolStatus = (value) => {
|
||||||
setPoolStatus(value);
|
setPoolStatus(value);
|
||||||
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
||||||
@ -333,6 +356,7 @@ export function usePrettyIdsPage() {
|
|||||||
reloadAll,
|
reloadAll,
|
||||||
resetIDFilters,
|
resetIDFilters,
|
||||||
resetPoolFilters,
|
resetPoolFilters,
|
||||||
|
recyclePrettyID,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setGenerateForm,
|
setGenerateForm,
|
||||||
setGrantForm,
|
setGrantForm,
|
||||||
@ -347,3 +371,10 @@ export function usePrettyIdsPage() {
|
|||||||
submitStatus,
|
submitStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRecyclePrettyID(item) {
|
||||||
|
const assigned = item?.assignedUserId;
|
||||||
|
const hasAssignedUser =
|
||||||
|
assigned !== 0 && assigned !== "0" && assigned !== "" && assigned !== undefined && assigned !== null;
|
||||||
|
return Boolean(item?.prettyId) && item.status === "assigned" && hasAssignedUser;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||||
|
import AssignmentReturnOutlined from "@mui/icons-material/AssignmentReturnOutlined";
|
||||||
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
@ -365,27 +366,43 @@ function idColumns(page) {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "minmax(96px, 0.5fr)",
|
width: "minmax(96px, 0.5fr)",
|
||||||
render: (item) =>
|
render: (item) => {
|
||||||
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
|
const showStatus = page.abilities.canPrettyUpdate && canChangePrettyStatus(item);
|
||||||
|
const showRecycle = page.abilities.canPrettyUpdate && canRecyclePrettyID(item);
|
||||||
|
if (!showStatus && !showRecycle) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
|
{showStatus ? (
|
||||||
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
||||||
<SettingsOutlined fontSize="small" />
|
<SettingsOutlined fontSize="small" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
|
{showRecycle ? (
|
||||||
|
<ActionIcon
|
||||||
|
disabled={page.loadingAction === `recycle-${item.prettyId}`}
|
||||||
|
label="回收"
|
||||||
|
tone="danger"
|
||||||
|
onClick={() => page.recyclePrettyID(item)}
|
||||||
|
>
|
||||||
|
<AssignmentReturnOutlined fontSize="small" />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
"-"
|
},
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignedUserCell({ item }) {
|
function AssignedUserCell({ item }) {
|
||||||
const assignedUserId = formatEntityID(item.assignedUserId);
|
const user = assignedUser(item);
|
||||||
if (!item.assignedUserId) {
|
if (!user) {
|
||||||
return <span className={styles.meta}>-</span>;
|
return <span className={styles.meta}>-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AdminUserIdentity openInAppUserDetail rows={[assignedUserId]} user={assignedUser(item)} />;
|
return <AdminUserIdentity openInAppUserDetail user={user} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperatorCell({ item }) {
|
function OperatorCell({ item }) {
|
||||||
@ -416,12 +433,16 @@ function PrettyIDTimeCell({ item }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assignedUser(item) {
|
function assignedUser(item) {
|
||||||
|
const user = item.assignedUser || null;
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...(item.assignedUser || {}),
|
...user,
|
||||||
displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId,
|
displayUserId: user.displayUserId || user.defaultDisplayUserId,
|
||||||
prettyDisplayUserId: item.displayUserId,
|
prettyDisplayUserId: item.displayUserId,
|
||||||
prettyId: item.prettyId,
|
prettyId: item.prettyId,
|
||||||
userId: item.assignedUser?.userId || item.assignedUserId,
|
userId: user.userId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -654,11 +675,11 @@ function StatusDialog({ page }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionIcon({ children, label, onClick }) {
|
function ActionIcon({ children, label, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip arrow title={label}>
|
<Tooltip arrow title={label}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton label={label} onClick={onClick}>
|
<IconButton label={label} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
@ -696,16 +717,16 @@ function ruleLabel(ruleType) {
|
|||||||
return prettyRuleOptions.find(([value]) => value === ruleType)?.[1] || ruleType || "-";
|
return prettyRuleOptions.find(([value]) => value === ruleType)?.[1] || ruleType || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntityID(value) {
|
|
||||||
if (value === 0 || value === "0" || value === "" || value === undefined || value === null) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canChangePrettyStatus(item) {
|
function canChangePrettyStatus(item) {
|
||||||
const assigned = item.assignedUserId;
|
const assigned = item.assignedUserId;
|
||||||
const unassigned =
|
const unassigned =
|
||||||
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
||||||
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
|
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRecyclePrettyID(item) {
|
||||||
|
const assigned = item.assignedUserId;
|
||||||
|
const hasAssignedUser =
|
||||||
|
assigned !== 0 && assigned !== "0" && assigned !== "" && assigned !== undefined && assigned !== null;
|
||||||
|
return item.status === "assigned" && hasAssignedUser;
|
||||||
|
}
|
||||||
|
|||||||
@ -19,10 +19,13 @@ test("pretty id page uses tabs and hides internal pretty record ids", () => {
|
|||||||
expect(screen.getByRole("tab", { name: "靓号列表 1" })).toHaveAttribute("aria-selected", "true");
|
expect(screen.getByRole("tab", { name: "靓号列表 1" })).toHaveAttribute("aria-selected", "true");
|
||||||
expect(screen.getByRole("tab", { name: "靓号池 1" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "靓号池 1" })).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("777777").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("777777").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.toBeInTheDocument();
|
expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("占用人 A")).toBeInTheDocument();
|
expect(screen.getByText("占用人 A")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('img[src="https://cdn.example/owner-a.png"]')).toBeInTheDocument();
|
||||||
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
|
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
|
||||||
expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument();
|
expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByLabelText("回收").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pretty id page can switch to pool tab", () => {
|
test("pretty id page can switch to pool tab", () => {
|
||||||
@ -107,6 +110,7 @@ function pageFixture(patch = {}) {
|
|||||||
reloadAll: vi.fn(),
|
reloadAll: vi.fn(),
|
||||||
resetIDFilters: vi.fn(),
|
resetIDFilters: vi.fn(),
|
||||||
resetPoolFilters: vi.fn(),
|
resetPoolFilters: vi.fn(),
|
||||||
|
recyclePrettyID: vi.fn(),
|
||||||
setActiveTab: vi.fn(),
|
setActiveTab: vi.fn(),
|
||||||
setGenerateForm: vi.fn(),
|
setGenerateForm: vi.fn(),
|
||||||
setGrantForm: vi.fn(),
|
setGrantForm: vi.fn(),
|
||||||
@ -141,7 +145,7 @@ function prettyIdFixture() {
|
|||||||
return {
|
return {
|
||||||
assignedAtMs: 1782499200000,
|
assignedAtMs: 1782499200000,
|
||||||
assignedUser: {
|
assignedUser: {
|
||||||
avatar: "",
|
avatar: "https://cdn.example/owner-a.png",
|
||||||
defaultDisplayUserId: "10001",
|
defaultDisplayUserId: "10001",
|
||||||
displayUserId: "777777",
|
displayUserId: "777777",
|
||||||
userId: "9001",
|
userId: "9001",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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 {
|
import {
|
||||||
|
adminAddAgencyHost,
|
||||||
createBDLeader,
|
createBDLeader,
|
||||||
createCountry,
|
createCountry,
|
||||||
createCoinSeller,
|
createCoinSeller,
|
||||||
@ -101,6 +102,11 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
rechargeAmount: "1.25",
|
rechargeAmount: "1.25",
|
||||||
reason: "deduct bad stock",
|
reason: "deduct bad stock",
|
||||||
});
|
});
|
||||||
|
await adminAddAgencyHost(7001, {
|
||||||
|
commandId: "agency-add-host-test",
|
||||||
|
reason: "manual add",
|
||||||
|
targetUserId: "1004",
|
||||||
|
});
|
||||||
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
||||||
|
|
||||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
@ -116,7 +122,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[10];
|
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[10];
|
||||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[11];
|
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[11];
|
||||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[12];
|
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[12];
|
||||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[13];
|
const [agencyAddHostUrl, agencyAddHostInit] = vi.mocked(fetch).mock.calls[13];
|
||||||
|
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[14];
|
||||||
|
|
||||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||||
@ -177,6 +184,12 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(coinSellerStockInit?.method).toBe("POST");
|
expect(coinSellerStockInit?.method).toBe("POST");
|
||||||
expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
|
expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
|
||||||
expect(coinSellerStockDebitInit?.method).toBe("POST");
|
expect(coinSellerStockDebitInit?.method).toBe("POST");
|
||||||
|
expect(String(agencyAddHostUrl)).toContain("/api/v1/admin/agencies/7001/hosts");
|
||||||
|
expect(agencyAddHostInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(agencyAddHostInit?.body))).toMatchObject({
|
||||||
|
commandId: "agency-add-host-test",
|
||||||
|
targetUserId: "1004",
|
||||||
|
});
|
||||||
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
|
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
|
||||||
expect(agencyDeleteInit?.method).toBe("POST");
|
expect(agencyDeleteInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen
|
|||||||
export { listRegions } from "@/shared/api/regions";
|
export { listRegions } from "@/shared/api/regions";
|
||||||
import type {
|
import type {
|
||||||
AgencyDto,
|
AgencyDto,
|
||||||
|
AgencyHostAddPayload,
|
||||||
AgencyJoinEnabledPayload,
|
AgencyJoinEnabledPayload,
|
||||||
AgencyStatusPayload,
|
AgencyStatusPayload,
|
||||||
ApiList,
|
ApiList,
|
||||||
@ -29,6 +30,7 @@ import type {
|
|||||||
CreateManagerPayload,
|
CreateManagerPayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
HostCommandPayload,
|
HostCommandPayload,
|
||||||
|
HostWithdrawalDto,
|
||||||
HostProfileDto,
|
HostProfileDto,
|
||||||
ManagerDto,
|
ManagerDto,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
@ -211,6 +213,14 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listHostWithdrawals(query: PageQuery = {}): Promise<ApiPage<HostWithdrawalDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listHostWithdrawals;
|
||||||
|
return apiRequest<ApiPage<HostWithdrawalDto>>(apiEndpointPath(API_OPERATIONS.listHostWithdrawals), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<SalaryWalletHistoryDto>> {
|
export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<SalaryWalletHistoryDto>> {
|
||||||
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
|
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -361,6 +371,17 @@ export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgency
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adminAddAgencyHost(agencyId: EntityId, payload: AgencyHostAddPayload): Promise<CreateAgencyResultDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.adminAddAgencyHost;
|
||||||
|
return apiRequest<CreateAgencyResultDto, AgencyHostAddPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.adminAddAgencyHost, { agency_id: agencyId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
|
export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
|
||||||
const endpoint = API_ENDPOINTS.closeAgency;
|
const endpoint = API_ENDPOINTS.closeAgency;
|
||||||
return apiRequest<AgencyDto, HostCommandPayload>(
|
return apiRequest<AgencyDto, HostCommandPayload>(
|
||||||
|
|||||||
@ -3,11 +3,18 @@ import { parseForm } from "@/shared/forms/validation";
|
|||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { createAgency, deleteAgency, listAgencies, setAgencyJoinEnabled, setAgencyStatus } from "@/features/host-org/api";
|
import {
|
||||||
|
adminAddAgencyHost,
|
||||||
|
createAgency,
|
||||||
|
deleteAgency,
|
||||||
|
listAgencies,
|
||||||
|
setAgencyJoinEnabled,
|
||||||
|
setAgencyStatus,
|
||||||
|
} from "@/features/host-org/api";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
||||||
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
|
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
|
||||||
import { createAgencySchema } from "@/features/host-org/schema";
|
import { agencyHostAddSchema, createAgencySchema } from "@/features/host-org/schema";
|
||||||
|
|
||||||
const emptyAgencyForm = () => ({
|
const emptyAgencyForm = () => ({
|
||||||
commandId: makeCommandId("agency"),
|
commandId: makeCommandId("agency"),
|
||||||
@ -17,6 +24,11 @@ const emptyAgencyForm = () => ({
|
|||||||
parentBdUserId: "",
|
parentBdUserId: "",
|
||||||
reason: "",
|
reason: "",
|
||||||
});
|
});
|
||||||
|
const emptyAgencyHostForm = () => ({
|
||||||
|
commandId: makeCommandId("agency-host"),
|
||||||
|
targetUserId: "",
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
@ -38,6 +50,8 @@ export function useHostAgenciesPage() {
|
|||||||
const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set());
|
const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set());
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
|
const [activeAgency, setActiveAgency] = useState(null);
|
||||||
|
const [agencyHostForm, setAgencyHostForm] = useState(emptyAgencyHostForm);
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
@ -146,6 +160,31 @@ export function useHostAgenciesPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAgencyHostForm = (agency) => {
|
||||||
|
if (!agency?.agencyId || agency.status !== "active") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveAgency(agency);
|
||||||
|
setAgencyHostForm(emptyAgencyHostForm());
|
||||||
|
setActiveAction("agency-host");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAgencyHost = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeAgency?.agencyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(`agency-host-${activeAgency.agencyId}`, "Host 已添加", async () => {
|
||||||
|
const payload = parseForm(agencyHostAddSchema, agencyHostForm);
|
||||||
|
const data = await adminAddAgencyHost(activeAgency.agencyId, payload);
|
||||||
|
setAgencyHostForm(emptyAgencyHostForm());
|
||||||
|
setActiveAgency(null);
|
||||||
|
setActiveAction("");
|
||||||
|
await reload();
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAgencyStatusFromList = async (agency, enabled) => {
|
const toggleAgencyStatusFromList = async (agency, enabled) => {
|
||||||
if (!abilities.canStatus || !agency?.agencyId) {
|
if (!abilities.canStatus || !agency?.agencyId) {
|
||||||
return;
|
return;
|
||||||
@ -230,7 +269,9 @@ export function useHostAgenciesPage() {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
|
activeAgency,
|
||||||
agencyForm,
|
agencyForm,
|
||||||
|
agencyHostForm,
|
||||||
changeOwnerFilter,
|
changeOwnerFilter,
|
||||||
changeParentBdFilter,
|
changeParentBdFilter,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
@ -250,15 +291,21 @@ export function useHostAgenciesPage() {
|
|||||||
regionOptions,
|
regionOptions,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
closeAction: () => setActiveAction(""),
|
closeAction: () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setActiveAgency(null);
|
||||||
|
},
|
||||||
openAgencyForm: () => setActiveAction("agency"),
|
openAgencyForm: () => setActiveAction("agency"),
|
||||||
|
openAgencyHostForm,
|
||||||
removeAgencyFromList,
|
removeAgencyFromList,
|
||||||
setAgencyForm,
|
setAgencyForm,
|
||||||
|
setAgencyHostForm,
|
||||||
setPage,
|
setPage,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
status,
|
status,
|
||||||
submitAgency,
|
submitAgency,
|
||||||
|
submitAgencyHost,
|
||||||
changeSort,
|
changeSort,
|
||||||
toggleAgencyStatusFromList,
|
toggleAgencyStatusFromList,
|
||||||
toggleAgencyJoinEnabledFromList,
|
toggleAgencyJoinEnabledFromList,
|
||||||
|
|||||||
90
src/features/host-org/hooks/useHostWithdrawalsPage.js
Normal file
90
src/features/host-org/hooks/useHostWithdrawalsPage.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { listHostWithdrawals } from "@/features/host-org/api";
|
||||||
|
import { useHostWithdrawalAbilities } from "@/features/host-org/permissions.js";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
const emptyTimeRange = { endMs: "", startMs: "" };
|
||||||
|
|
||||||
|
export function useHostWithdrawalsPage() {
|
||||||
|
const abilities = useHostWithdrawalAbilities();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [recordType, setRecordType] = useState("");
|
||||||
|
const [identity, setIdentity] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState(emptyTimeRange);
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
end_at_ms: timeRange.endMs || "",
|
||||||
|
identity,
|
||||||
|
keyword,
|
||||||
|
record_type: recordType,
|
||||||
|
start_at_ms: timeRange.startMs || "",
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[identity, keyword, recordType, status, timeRange.endMs, timeRange.startMs],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "加载主播提现列表失败",
|
||||||
|
fetcher: listHostWithdrawals,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["host-org", "host-withdrawals", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || emptyData;
|
||||||
|
const hasActiveFilters = Boolean(recordType || identity || status || keyword || timeRange.startMs || timeRange.endMs);
|
||||||
|
|
||||||
|
const changeRecordType = (value) => {
|
||||||
|
setRecordType(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeIdentity = (value) => {
|
||||||
|
setIdentity(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeKeyword = (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeTimeRange = (nextRange) => {
|
||||||
|
setTimeRange(nextRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setRecordType("");
|
||||||
|
setIdentity("");
|
||||||
|
setStatus("");
|
||||||
|
setKeyword("");
|
||||||
|
setTimeRange(emptyTimeRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
changeIdentity,
|
||||||
|
changeKeyword,
|
||||||
|
changeRecordType,
|
||||||
|
changeStatus,
|
||||||
|
changeTimeRange,
|
||||||
|
data,
|
||||||
|
error: query.error,
|
||||||
|
hasActiveFilters,
|
||||||
|
identity,
|
||||||
|
keyword,
|
||||||
|
loading: query.loading,
|
||||||
|
page,
|
||||||
|
recordType,
|
||||||
|
reload: query.reload,
|
||||||
|
resetFilters,
|
||||||
|
setPage,
|
||||||
|
status,
|
||||||
|
timeRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -99,6 +100,7 @@ export function HostAgenciesPage() {
|
|||||||
const page = useHostAgenciesPage();
|
const page = useHostAgenciesPage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const createDisabled = !page.abilities.canCreate;
|
const createDisabled = !page.abilities.canCreate;
|
||||||
|
const agencyHostForm = page.agencyHostForm || { reason: "", targetUserId: "" };
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const openAgencyHosts = (agency) => {
|
const openAgencyHosts = (agency) => {
|
||||||
@ -245,6 +247,35 @@ export function HostAgenciesPage() {
|
|||||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
|
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
|
<HostOrgActionModal
|
||||||
|
disabled={!page.abilities.canCreate || page.activeAgency?.status !== "active"}
|
||||||
|
loading={page.loadingAction === `agency-host-${page.activeAgency?.agencyId}`}
|
||||||
|
open={page.activeAction === "agency-host"}
|
||||||
|
sectionTitle="Host 信息"
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitAgencyHost}
|
||||||
|
title="增加 Host"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canCreate}
|
||||||
|
label="Host 用户短 ID"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={agencyHostForm.targetUserId}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.setAgencyHostForm?.({ ...agencyHostForm, targetUserId: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canCreate}
|
||||||
|
label="原因"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={agencyHostForm.reason}
|
||||||
|
onChange={(event) => page.setAgencyHostForm?.({ ...agencyHostForm, reason: event.target.value })}
|
||||||
|
/>
|
||||||
|
</HostOrgActionModal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -312,6 +343,16 @@ function AgencyJoinSwitch({ item, page }) {
|
|||||||
function AgencyActions({ item, page }) {
|
function AgencyActions({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
|
{page?.abilities.canCreate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={item.status !== "active" || page.loadingAction === `agency-host-${item.agencyId}`}
|
||||||
|
label="增加 Host"
|
||||||
|
primary
|
||||||
|
onClick={() => page.openAgencyHostForm(item)}
|
||||||
|
>
|
||||||
|
<PersonAddAlt1Outlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
{page?.abilities.canSalaryAdjust ? (
|
{page?.abilities.canSalaryAdjust ? (
|
||||||
<SalaryWalletAdjustButton
|
<SalaryWalletAdjustButton
|
||||||
role="agency"
|
role="agency"
|
||||||
|
|||||||
276
src/features/host-org/pages/HostWithdrawalsPage.jsx
Normal file
276
src/features/host-org/pages/HostWithdrawalsPage.jsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { useHostWithdrawalsPage } from "@/features/host-org/hooks/useHostWithdrawalsPage.js";
|
||||||
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
|
||||||
|
const recordTypeOptions = [
|
||||||
|
["", "全部"],
|
||||||
|
["salary_transfer", "工资转币商"],
|
||||||
|
["withdrawal", "提现申请"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const identityOptions = [
|
||||||
|
["", "全部"],
|
||||||
|
["host", "Host"],
|
||||||
|
["agency", "Agency"],
|
||||||
|
["bd", "BD"],
|
||||||
|
["bd_leader_admin", "BD Leader/Admin"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部"],
|
||||||
|
["completed", "已完成"],
|
||||||
|
["pending", "待审核"],
|
||||||
|
["approved", "已通过"],
|
||||||
|
["rejected", "已拒绝"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const recordTypeLabels = Object.fromEntries(recordTypeOptions.filter(([value]) => value));
|
||||||
|
const identityLabels = Object.fromEntries(identityOptions.filter(([value]) => value));
|
||||||
|
const statusLabels = Object.fromEntries(statusOptions.filter(([value]) => value));
|
||||||
|
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "时间",
|
||||||
|
render: (item) => formatMillis(item.createdAtMs),
|
||||||
|
width: "minmax(170px, 0.8fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recordType",
|
||||||
|
label: "类型",
|
||||||
|
render: (item) => recordTypeLabel(item.recordType),
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "identity",
|
||||||
|
label: "身份",
|
||||||
|
render: (item) => identityLabel(item.identity, item.salaryAssetType),
|
||||||
|
width: "minmax(150px, 0.65fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sourceUser",
|
||||||
|
label: "发起用户",
|
||||||
|
render: (item) => <AdminUserIdentity openInAppUserDetail user={item.sourceUser} userId={item.sourceUserId} />,
|
||||||
|
width: "minmax(230px, 1fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "receiver",
|
||||||
|
label: "币商/收款",
|
||||||
|
render: (item) => <ReceiverCell item={item} />,
|
||||||
|
width: "minmax(240px, 1.05fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "usdMinorAmount",
|
||||||
|
label: "USD金额",
|
||||||
|
render: (item) => <AmountText value={`$${formatUSDMinor(item.usdMinorAmount)}`} />,
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "coinAmount",
|
||||||
|
label: "币商金币",
|
||||||
|
render: (item) => formatCoinAmount(item),
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
render: (item) => <StatusBadge status={item.status} />,
|
||||||
|
width: "minmax(110px, 0.55fr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recordId",
|
||||||
|
label: "交易/申请ID",
|
||||||
|
render: (item) => <RecordIdCell item={item} />,
|
||||||
|
width: "minmax(260px, 1.1fr)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HostWithdrawalsPage() {
|
||||||
|
const page = useHostWithdrawalsPage();
|
||||||
|
const data = page.data || { items: [], page: 1, pageSize: 50, total: 0 };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = Number(data.total || 0);
|
||||||
|
const tableColumns = baseColumns.map((column) => {
|
||||||
|
if (column.key === "recordType") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部",
|
||||||
|
options: recordTypeOptions,
|
||||||
|
placeholder: "类型",
|
||||||
|
value: page.recordType,
|
||||||
|
onChange: page.changeRecordType,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "identity") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部",
|
||||||
|
options: identityOptions,
|
||||||
|
placeholder: "身份",
|
||||||
|
value: page.identity,
|
||||||
|
onChange: page.changeIdentity,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "sourceUser") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
activeLabel: page.keyword ? `用户 ${page.keyword}` : "",
|
||||||
|
placeholder: "用户/交易/地址",
|
||||||
|
value: page.keyword,
|
||||||
|
onChange: page.changeKeyword,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部",
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: "状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<TimeRangeFilter value={page.timeRange} onChange={page.changeTimeRange} />
|
||||||
|
<AdminFilterResetButton disabled={!page.hasActiveFilters} onClick={page.resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
emptyLabel="当前无主播提现记录"
|
||||||
|
items={items}
|
||||||
|
minWidth="1510px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: data.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiverCell({ item }) {
|
||||||
|
if (item.recordType === "salary_transfer") {
|
||||||
|
return <AdminUserIdentity openInAppUserDetail user={item.sellerUser} userId={item.sellerUserId} />;
|
||||||
|
}
|
||||||
|
const method = String(item.withdrawMethod || "").trim();
|
||||||
|
const address = String(item.withdrawAddress || "").trim();
|
||||||
|
if (!method && !address) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{method || "-"}</span>
|
||||||
|
{address ? <span className={styles.meta}>{address}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordIdCell({ item }) {
|
||||||
|
const primary = item.transactionId || item.applicationId || item.id || "-";
|
||||||
|
const secondary = item.commandId || item.freezeTransactionId || item.auditTransactionId || "";
|
||||||
|
return (
|
||||||
|
<span className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{primary}</span>
|
||||||
|
{secondary ? <span className={styles.meta}>{secondary}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const normalized = String(status || "").trim();
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge--${statusTone(normalized)}`}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{statusLabel(normalized)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountText({ value }) {
|
||||||
|
return <span className={styles.amount}>{value}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTypeLabel(value) {
|
||||||
|
return recordTypeLabels[value] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityLabel(identity, salaryAssetType) {
|
||||||
|
return identityLabels[identity] || salaryAssetLabel(salaryAssetType) || identity || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function salaryAssetLabel(assetType) {
|
||||||
|
const labels = {
|
||||||
|
AGENCY_SALARY_USD: "Agency",
|
||||||
|
ADMIN_SALARY_USD: "BD Leader/Admin",
|
||||||
|
BD_SALARY_USD: "BD",
|
||||||
|
HOST_SALARY_USD: "Host",
|
||||||
|
};
|
||||||
|
return labels[assetType] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return statusLabels[status] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status) {
|
||||||
|
if (status === "completed" || status === "approved") {
|
||||||
|
return "succeeded";
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
if (status === "rejected") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
return "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoinAmount(item) {
|
||||||
|
if (item.recordType !== "salary_transfer") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Number(item.coinAmount || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUSDMinor(value) {
|
||||||
|
return (Number(value || 0) / 100).toFixed(2);
|
||||||
|
}
|
||||||
175
src/features/host-org/pages/HostWithdrawalsPage.test.jsx
Normal file
175
src/features/host-org/pages/HostWithdrawalsPage.test.jsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { HostWithdrawalsPage } from "./HostWithdrawalsPage.jsx";
|
||||||
|
import { useHostWithdrawalsPage } from "@/features/host-org/hooks/useHostWithdrawalsPage.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
changeIdentity: vi.fn(),
|
||||||
|
changeKeyword: vi.fn(),
|
||||||
|
changeRecordType: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
changeTimeRange: vi.fn(),
|
||||||
|
dataTable: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/hooks/useHostWithdrawalsPage.js", () => ({
|
||||||
|
useHostWithdrawalsPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/DataTable.jsx", () => ({
|
||||||
|
DataTable: (props) => {
|
||||||
|
mocks.dataTable(props);
|
||||||
|
return (
|
||||||
|
<div data-testid="host-withdrawals-table">
|
||||||
|
{props.columns.map((column) => (
|
||||||
|
<div data-testid={`column-${column.key}`} key={column.key}>
|
||||||
|
{column.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{props.items.map((item) => (
|
||||||
|
<div data-testid={`row-${item.id}`} key={item.id}>
|
||||||
|
{props.columns.map((column) => (
|
||||||
|
<div key={column.key}>{column.render ? column.render(item) : item[column.key]}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/shared/ui/TimeRangeFilter.jsx", () => ({
|
||||||
|
TimeRangeFilter: ({ onChange }) => (
|
||||||
|
<button type="button" onClick={() => onChange({ endMs: "2000", startMs: "1000" })}>
|
||||||
|
时间
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host withdrawals page renders salary transfer and withdrawal records", () => {
|
||||||
|
vi.mocked(useHostWithdrawalsPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
|
render(<HostWithdrawalsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("工资转币商")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("提现申请")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Host")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("BD Leader/Admin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("$123.45")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1,112")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已完成")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("待审核")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("USDT-TRC20")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TRON9ADDRESS")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("tx-salary-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("9")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host withdrawals filters are wired to the page hook", () => {
|
||||||
|
vi.mocked(useHostWithdrawalsPage).mockReturnValue(pageFixture({ hasActiveFilters: true }));
|
||||||
|
|
||||||
|
render(<HostWithdrawalsPage />);
|
||||||
|
|
||||||
|
const props = mocks.dataTable.mock.calls.at(-1)?.[0];
|
||||||
|
props.columns.find((column) => column.key === "recordType").filter.onChange("withdrawal");
|
||||||
|
props.columns.find((column) => column.key === "identity").filter.onChange("bd_leader_admin");
|
||||||
|
props.columns.find((column) => column.key === "sourceUser").filter.onChange("168557");
|
||||||
|
props.columns.find((column) => column.key === "status").filter.onChange("approved");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "时间" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重置" }));
|
||||||
|
|
||||||
|
expect(mocks.changeRecordType).toHaveBeenCalledWith("withdrawal");
|
||||||
|
expect(mocks.changeIdentity).toHaveBeenCalledWith("bd_leader_admin");
|
||||||
|
expect(mocks.changeKeyword).toHaveBeenCalledWith("168557");
|
||||||
|
expect(mocks.changeStatus).toHaveBeenCalledWith("approved");
|
||||||
|
expect(mocks.changeTimeRange).toHaveBeenCalledWith({ endMs: "2000", startMs: "1000" });
|
||||||
|
expect(mocks.resetFilters).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: { canView: true },
|
||||||
|
changeIdentity: mocks.changeIdentity,
|
||||||
|
changeKeyword: mocks.changeKeyword,
|
||||||
|
changeRecordType: mocks.changeRecordType,
|
||||||
|
changeStatus: mocks.changeStatus,
|
||||||
|
changeTimeRange: mocks.changeTimeRange,
|
||||||
|
data: {
|
||||||
|
items: [salaryTransferFixture(), withdrawalFixture()],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
hasActiveFilters: false,
|
||||||
|
identity: "",
|
||||||
|
keyword: "",
|
||||||
|
loading: false,
|
||||||
|
page: 1,
|
||||||
|
recordType: "",
|
||||||
|
reload: vi.fn(),
|
||||||
|
resetFilters: mocks.resetFilters,
|
||||||
|
setPage: vi.fn(),
|
||||||
|
status: "",
|
||||||
|
timeRange: { endMs: "", startMs: "" },
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function salaryTransferFixture() {
|
||||||
|
return {
|
||||||
|
coinAmount: 1112,
|
||||||
|
commandId: "cmd-salary-1",
|
||||||
|
createdAtMs: 1780000000000,
|
||||||
|
id: "salary_transfer:101",
|
||||||
|
identity: "host",
|
||||||
|
recordType: "salary_transfer",
|
||||||
|
salaryAssetType: "HOST_SALARY_USD",
|
||||||
|
sellerUser: {
|
||||||
|
displayUserId: "168556",
|
||||||
|
userId: "3001",
|
||||||
|
username: "Coin Seller",
|
||||||
|
},
|
||||||
|
sellerUserId: "3001",
|
||||||
|
sourceUser: {
|
||||||
|
displayUserId: "168577",
|
||||||
|
userId: "1001",
|
||||||
|
username: "Raj",
|
||||||
|
},
|
||||||
|
sourceUserId: "1001",
|
||||||
|
status: "completed",
|
||||||
|
transactionId: "tx-salary-1",
|
||||||
|
updatedAtMs: 1780000000000,
|
||||||
|
usdMinorAmount: 12345,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withdrawalFixture() {
|
||||||
|
return {
|
||||||
|
applicationId: "9",
|
||||||
|
coinAmount: 0,
|
||||||
|
commandId: "freeze-cmd-9",
|
||||||
|
createdAtMs: 1780000001000,
|
||||||
|
freezeTransactionId: "freeze-tx-9",
|
||||||
|
id: "withdrawal:9",
|
||||||
|
identity: "bd_leader_admin",
|
||||||
|
recordType: "withdrawal",
|
||||||
|
salaryAssetType: "ADMIN_SALARY_USD",
|
||||||
|
sourceUser: {
|
||||||
|
displayUserId: "481479",
|
||||||
|
userId: "2002",
|
||||||
|
username: "Mao",
|
||||||
|
},
|
||||||
|
sourceUserId: "2002",
|
||||||
|
status: "pending",
|
||||||
|
updatedAtMs: 1780000001000,
|
||||||
|
usdMinorAmount: 5000,
|
||||||
|
withdrawAddress: "TRON9ADDRESS",
|
||||||
|
withdrawMethod: "USDT-TRC20",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -67,3 +67,11 @@ export function useCoinSellerAbilities() {
|
|||||||
canView: can(PERMISSIONS.coinSellerView),
|
canView: can(PERMISSIONS.coinSellerView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useHostWithdrawalAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canView: can(PERMISSIONS.hostWithdrawalView),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -65,4 +65,12 @@ export const hostOrgRoutes = [
|
|||||||
path: "/host/coin-sellers",
|
path: "/host/coin-sellers",
|
||||||
permission: PERMISSIONS.coinSellerView,
|
permission: PERMISSIONS.coinSellerView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "主播提现",
|
||||||
|
loader: () => import("./pages/HostWithdrawalsPage.jsx").then((module) => module.HostWithdrawalsPage),
|
||||||
|
menuCode: MENU_CODES.hostWithdrawals,
|
||||||
|
pageKey: "host-withdrawals",
|
||||||
|
path: "/host/withdrawals",
|
||||||
|
permission: PERMISSIONS.hostWithdrawalView,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -165,6 +165,10 @@ export const createAgencySchema = commandBaseSchema.extend({
|
|||||||
parentBdUserId: optionalUserIdSchema,
|
parentBdUserId: optionalUserIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const agencyHostAddSchema = commandBaseSchema.extend({
|
||||||
|
targetUserId: userIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const agencyCloseSchema = commandBaseSchema.extend({
|
export const agencyCloseSchema = commandBaseSchema.extend({
|
||||||
agencyId: entityIdSchema,
|
agencyId: entityIdSchema,
|
||||||
});
|
});
|
||||||
@ -211,6 +215,7 @@ export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
|||||||
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>;
|
||||||
|
export type AgencyHostAddForm = z.infer<typeof agencyHostAddSchema>;
|
||||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
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>;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const emptyData = { permissions: [], roles: [] };
|
|||||||
const permissionGroupDefinitions = [
|
const permissionGroupDefinitions = [
|
||||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||||
{ key: "users", prefixes: ["user"], title: "后台用户" },
|
{ key: "users", prefixes: ["user"], title: "后台用户" },
|
||||||
|
{ key: "teams", prefixes: ["team"], title: "团队配置" },
|
||||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||||
{ key: "app-users", prefixes: ["app-user", "level-config", "region-block"], title: "APP 用户" },
|
{ key: "app-users", prefixes: ["app-user", "level-config", "region-block"], title: "APP 用户" },
|
||||||
|
|||||||
45
src/features/teams/api.test.ts
Normal file
45
src/features/teams/api.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
|
import { createTeam, listOperationUsers, listTeams, listTeamUsers, OPERATION_TEAM_ID, updateTeam } from "./api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAccessToken("");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("team APIs use generated endpoint paths", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] })))
|
||||||
|
);
|
||||||
|
|
||||||
|
await listTeams({ keyword: "运营" });
|
||||||
|
await createTeam({ description: "平台运营", name: "运营一部", parentId: 2, sort: 2 });
|
||||||
|
await updateTeam(2, { description: "平台运营", name: "运营团队", parentId: null, sort: 2 });
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/teams?");
|
||||||
|
expect(String(calls[0][0])).toContain("keyword=%E8%BF%90%E8%90%A5");
|
||||||
|
expect(calls[0][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[1][0])).toContain("/api/v1/teams");
|
||||||
|
expect(calls[1][1]?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ description: "平台运营", name: "运营一部", parentId: 2, sort: 2 });
|
||||||
|
expect(String(calls[2][0])).toContain("/api/v1/teams/2");
|
||||||
|
expect(calls[2][1]?.method).toBe("PATCH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("operation users are loaded from operation team id 2", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } })))
|
||||||
|
);
|
||||||
|
|
||||||
|
await listTeamUsers(1);
|
||||||
|
await listOperationUsers({ status: "active" });
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
|
expect(OPERATION_TEAM_ID).toBe(2);
|
||||||
|
expect(String(calls[0][0])).toContain("/api/v1/teams/1/users");
|
||||||
|
expect(String(calls[1][0])).toContain("/api/v1/teams/2/users?");
|
||||||
|
expect(String(calls[1][0])).toContain("status=active");
|
||||||
|
});
|
||||||
34
src/features/teams/api.ts
Normal file
34
src/features/teams/api.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { AdminUserDto, ApiPage, EntityId, PageQuery, TeamDto, TeamPayload } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export const OPERATION_TEAM_ID = 2;
|
||||||
|
|
||||||
|
export async function listTeams(query?: Pick<PageQuery, "keyword">): Promise<TeamDto[]> {
|
||||||
|
const data = await apiRequest<TeamDto[] | { items?: TeamDto[] }>(apiEndpointPath(API_OPERATIONS.listTeams), { query });
|
||||||
|
return Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTeam(payload: TeamPayload): Promise<TeamDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.createTeam;
|
||||||
|
return apiRequest<TeamDto, TeamPayload>(apiEndpointPath(API_OPERATIONS.createTeam), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTeam(id: EntityId, payload: TeamPayload): Promise<TeamDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateTeam;
|
||||||
|
return apiRequest<TeamDto, TeamPayload>(apiEndpointPath(API_OPERATIONS.updateTeam, { team_id: id }), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTeamUsers(teamId: EntityId, query?: Pick<PageQuery, "keyword" | "status">): Promise<ApiPage<AdminUserDto>> {
|
||||||
|
return apiRequest<ApiPage<AdminUserDto>>(apiEndpointPath(API_OPERATIONS.listTeamUsers, { team_id: teamId }), { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOperationUsers(query?: Pick<PageQuery, "keyword" | "status">): Promise<ApiPage<AdminUserDto>> {
|
||||||
|
return listTeamUsers(OPERATION_TEAM_ID, query);
|
||||||
|
}
|
||||||
55
src/features/teams/components/TeamFormDrawer.jsx
Normal file
55
src/features/teams/components/TeamFormDrawer.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
|
||||||
|
export function TeamFormDrawer({ editingTeam, form, onClose, onSubmit, open, parentOptions = [], setForm }) {
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
|
<form className="form-drawer" onSubmit={onSubmit}>
|
||||||
|
<h2>{editingTeam ? "编辑团队" : "新增团队"}</h2>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className="form-drawer__section-title">基础信息</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
label="父级团队"
|
||||||
|
select
|
||||||
|
value={form.parentId ?? ""}
|
||||||
|
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="">一级团队</MenuItem>
|
||||||
|
{parentOptions.map((team) => (
|
||||||
|
<MenuItem key={team.id} value={team.id}>
|
||||||
|
{team.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="团队名称"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={form.sort}
|
||||||
|
onChange={(event) => setForm({ ...form, sort: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="说明"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="form-drawer__actions">
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button type="submit" variant="primary">提交</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/features/teams/components/TeamTable.jsx
Normal file
62
src/features/teams/components/TeamTable.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
|
||||||
|
export function TeamTable({ abilities, onCreateChild, onEdit, onQueryChange, operationTeamId, pagination, query, teams }) {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "团队",
|
||||||
|
width: "minmax(180px, 1.2fr)",
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索团队名称、ID",
|
||||||
|
value: query,
|
||||||
|
onChange: onQueryChange
|
||||||
|
}),
|
||||||
|
render: (team) => (
|
||||||
|
<span className={team.parentId ? "team-name-cell team-name-cell--child" : "team-name-cell"}>
|
||||||
|
<strong>{team.name}</strong>
|
||||||
|
{team.parentName ? <small>{team.parentName}</small> : null}
|
||||||
|
{Number(team.id) === Number(operationTeamId) ? <small>运营人员来源</small> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: "id", label: "团队 ID", width: "120px", render: (team) => team.id },
|
||||||
|
{
|
||||||
|
key: "userCount",
|
||||||
|
label: "成员数",
|
||||||
|
width: "120px",
|
||||||
|
render: (team) => Number(team.userCount ?? 0).toLocaleString("en-US")
|
||||||
|
},
|
||||||
|
{ key: "sort", label: "排序", width: "100px", render: (team) => team.sort ?? 0 },
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "说明",
|
||||||
|
width: "minmax(240px, 1.5fr)",
|
||||||
|
render: (team) => <span className="muted">{team.description || "-"}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "148px",
|
||||||
|
render: (team) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
{abilities.canCreate && !team.parentId ? (
|
||||||
|
<AdminActionIconButton label="新增子部门" onClick={() => onCreateChild(team)}>
|
||||||
|
<Add fontSize="inherit" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
{abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton label="编辑" onClick={() => onEdit(team)}>
|
||||||
|
<EditOutlined fontSize="inherit" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null}
|
||||||
|
</AdminRowActions>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return <DataTable columns={columns} items={teams} minWidth="920px" pagination={pagination} rowKey={(team) => team.id} />;
|
||||||
|
}
|
||||||
132
src/features/teams/hooks/useTeamsPage.js
Normal file
132
src/features/teams/hooks/useTeamsPage.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { createTeam, listTeams, OPERATION_TEAM_ID, updateTeam } from "@/features/teams/api";
|
||||||
|
import { useTeamAbilities } from "@/features/teams/permissions.js";
|
||||||
|
import { teamFormSchema } from "@/features/teams/schema";
|
||||||
|
|
||||||
|
const emptyForm = { description: "", name: "", parentId: "", sort: 0 };
|
||||||
|
|
||||||
|
export function useTeamsPage() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editingTeam, setEditingTeam] = useState(null);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const abilities = useTeamAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const queryFn = useCallback(() => listTeams(), []);
|
||||||
|
const { data, error, loading, reload } = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载团队失败",
|
||||||
|
initialData: [],
|
||||||
|
keepPreviousData: true,
|
||||||
|
queryKey: ["teams"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTeams = useMemo(() => flattenTeams(data), [data]);
|
||||||
|
|
||||||
|
const teams = useMemo(() => {
|
||||||
|
const keyword = query.trim().toLowerCase();
|
||||||
|
const items = allTeams;
|
||||||
|
if (!keyword) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return items.filter((team) =>
|
||||||
|
[team.id, team.name, team.parentName, team.description].some((value) =>
|
||||||
|
String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [allTeams, query]);
|
||||||
|
|
||||||
|
const parentOptions = useMemo(
|
||||||
|
() => allTeams.filter((team) => !team.parentId && (!editingTeam || String(team.id) !== String(editingTeam.id))),
|
||||||
|
[allTeams, editingTeam],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = (parentTeam) => {
|
||||||
|
setEditingTeam(null);
|
||||||
|
setForm({ ...emptyForm, parentId: parentTeam?.id || "" });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (team) => {
|
||||||
|
setEditingTeam(team);
|
||||||
|
setForm({
|
||||||
|
description: team.description || "",
|
||||||
|
name: team.name || "",
|
||||||
|
parentId: team.parentId || "",
|
||||||
|
sort: Number(team.sort || 0),
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = parseForm(teamFormSchema, form);
|
||||||
|
if (editingTeam) {
|
||||||
|
await updateTeam(editingTeam.id, payload);
|
||||||
|
showToast("团队已更新", "success");
|
||||||
|
} else {
|
||||||
|
await createTeam(payload);
|
||||||
|
showToast("团队已创建", "success");
|
||||||
|
}
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存团队失败", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
closeDrawer,
|
||||||
|
drawerOpen,
|
||||||
|
editingTeam,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
operationTeamId: OPERATION_TEAM_ID,
|
||||||
|
parentOptions,
|
||||||
|
query,
|
||||||
|
reload,
|
||||||
|
setForm,
|
||||||
|
setQuery,
|
||||||
|
submit,
|
||||||
|
teams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTeams(data) {
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const flattened = [];
|
||||||
|
items.forEach((team) => {
|
||||||
|
appendTeam(flattened, seen, team);
|
||||||
|
(team.children || []).forEach((child) => {
|
||||||
|
appendTeam(flattened, seen, {
|
||||||
|
...child,
|
||||||
|
parentId: child.parentId ?? team.id,
|
||||||
|
parentName: child.parentName || team.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTeam(target, seen, team) {
|
||||||
|
if (!team?.id || seen.has(String(team.id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(String(team.id));
|
||||||
|
target.push(team);
|
||||||
|
}
|
||||||
61
src/features/teams/pages/TeamManagementPage.jsx
Normal file
61
src/features/teams/pages/TeamManagementPage.jsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { TeamFormDrawer } from "@/features/teams/components/TeamFormDrawer.jsx";
|
||||||
|
import { TeamTable } from "@/features/teams/components/TeamTable.jsx";
|
||||||
|
import { useTeamsPage } from "@/features/teams/hooks/useTeamsPage.js";
|
||||||
|
import "@/features/users/users.css";
|
||||||
|
|
||||||
|
export function TeamManagementPage() {
|
||||||
|
const page = useTeamsPage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canCreate ? (
|
||||||
|
<AdminActionIconButton label="新增团队" primary onClick={page.openCreate}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<TeamTable
|
||||||
|
abilities={page.abilities}
|
||||||
|
onCreateChild={page.openCreate}
|
||||||
|
onEdit={page.openEdit}
|
||||||
|
onQueryChange={page.setQuery}
|
||||||
|
operationTeamId={page.operationTeamId}
|
||||||
|
pagination={{
|
||||||
|
page: 1,
|
||||||
|
pageSize: Math.max(page.teams.length, 1),
|
||||||
|
total: page.teams.length,
|
||||||
|
}}
|
||||||
|
query={page.query}
|
||||||
|
teams={page.teams}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
|
||||||
|
<TeamFormDrawer
|
||||||
|
editingTeam={page.editingTeam}
|
||||||
|
form={page.form}
|
||||||
|
onClose={page.closeDrawer}
|
||||||
|
onSubmit={page.submit}
|
||||||
|
open={page.drawerOpen}
|
||||||
|
parentOptions={page.parentOptions}
|
||||||
|
setForm={page.setForm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/features/teams/permissions.js
Normal file
12
src/features/teams/permissions.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useTeamAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canCreate: can(PERMISSIONS.teamCreate),
|
||||||
|
canUpdate: can(PERMISSIONS.teamUpdate),
|
||||||
|
canView: can(PERMISSIONS.teamView)
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/teams/routes.js
Normal file
12
src/features/teams/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const teamsRoutes = [
|
||||||
|
{
|
||||||
|
label: "团队配置",
|
||||||
|
loader: () => import("./pages/TeamManagementPage.jsx").then((module) => module.TeamManagementPage),
|
||||||
|
menuCode: MENU_CODES.systemTeams,
|
||||||
|
pageKey: "teams",
|
||||||
|
path: "/system/teams",
|
||||||
|
permission: PERMISSIONS.teamView
|
||||||
|
}
|
||||||
|
];
|
||||||
18
src/features/teams/schema.test.ts
Normal file
18
src/features/teams/schema.test.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
|
import { teamFormSchema } from "./schema";
|
||||||
|
|
||||||
|
describe("team form schema", () => {
|
||||||
|
test("normalizes team payload", () => {
|
||||||
|
expect(parseForm(teamFormSchema, { description: " 平台运营 ", name: " 运营一部 ", parentId: "2", sort: "2" })).toEqual({
|
||||||
|
description: "平台运营",
|
||||||
|
name: "运营一部",
|
||||||
|
parentId: 2,
|
||||||
|
sort: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects empty team name", () => {
|
||||||
|
expect(() => parseForm(teamFormSchema, { description: "", name: "", sort: 0 })).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/features/teams/schema.ts
Normal file
10
src/features/teams/schema.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const teamFormSchema = z.object({
|
||||||
|
description: z.string().trim().max(160, "说明不能超过 160 个字符").optional().default(""),
|
||||||
|
name: z.string().trim().min(1, "请输入团队名称").max(50, "团队名称不能超过 50 个字符"),
|
||||||
|
parentId: z.union([z.literal(""), z.coerce.number().int().positive("请选择父级团队")]).optional().transform((value) => value === "" ? undefined : value),
|
||||||
|
sort: z.coerce.number().int("排序必须是整数").min(0, "排序不能小于 0").max(9999, "排序不能超过 9999").default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TeamForm = z.infer<typeof teamFormSchema>;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
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 { getMoneyScopeCatalog, listUserMoneyScopes, listUsers, replaceUserMoneyScopes, updateUserStatus } from "./api";
|
import { getFinanceScopeCatalog, listUserFinanceScopes, listUsers, replaceUserFinanceScopes, updateUserStatus } from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
@ -13,7 +13,7 @@ test("listUsers uses generated endpoint path and query", async () => {
|
|||||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })))
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })))
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await listUsers({ page: 1, page_size: 10, status: "active" });
|
const result = await listUsers({ page: 1, page_size: 10, status: "active", team_id: 2 });
|
||||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
expect(result.items).toEqual([]);
|
expect(result.items).toEqual([]);
|
||||||
@ -21,6 +21,7 @@ test("listUsers uses generated endpoint path and query", async () => {
|
|||||||
expect(String(url)).toContain("page=1");
|
expect(String(url)).toContain("page=1");
|
||||||
expect(String(url)).toContain("page_size=10");
|
expect(String(url)).toContain("page_size=10");
|
||||||
expect(String(url)).toContain("status=active");
|
expect(String(url)).toContain("status=active");
|
||||||
|
expect(String(url)).toContain("team_id=2");
|
||||||
expect(init?.method).toBe("GET");
|
expect(init?.method).toBe("GET");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,22 +39,22 @@ test("updateUserStatus sends generated endpoint path and payload", async () => {
|
|||||||
expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" });
|
expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("money scope APIs use generated endpoint paths", async () => {
|
test("finance scope APIs use generated endpoint paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { apps: [], items: [], total: 0 } })))
|
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { apps: [], items: [], total: 0 } })))
|
||||||
);
|
);
|
||||||
|
|
||||||
await listUserMoneyScopes(9);
|
await listUserFinanceScopes(9);
|
||||||
await replaceUserMoneyScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
||||||
await getMoneyScopeCatalog();
|
await getFinanceScopeCatalog();
|
||||||
|
|
||||||
const calls = vi.mocked(fetch).mock.calls;
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
expect(String(calls[0][0])).toContain("/api/v1/users/9/money-scopes");
|
expect(String(calls[0][0])).toContain("/api/v1/users/9/finance-scopes");
|
||||||
expect(calls[0][1]?.method).toBe("GET");
|
expect(calls[0][1]?.method).toBe("GET");
|
||||||
expect(String(calls[1][0])).toContain("/api/v1/users/9/money-scopes");
|
expect(String(calls[1][0])).toContain("/api/v1/users/9/finance-scopes");
|
||||||
expect(calls[1][1]?.method).toBe("PUT");
|
expect(calls[1][1]?.method).toBe("PUT");
|
||||||
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ scopes: [{ appCode: "lalu", regionId: 2 }] });
|
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ scopes: [{ appCode: "lalu", regionId: 2 }] });
|
||||||
expect(String(calls[2][0])).toContain("/api/v1/admin/money/scope");
|
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/scope");
|
||||||
expect(calls[2][1]?.method).toBe("GET");
|
expect(calls[2][1]?.method).toBe("GET");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type {
|
|||||||
PageQuery,
|
PageQuery,
|
||||||
ResetPasswordResultDto,
|
ResetPasswordResultDto,
|
||||||
UserFormPayload,
|
UserFormPayload,
|
||||||
UserMoneyScopeDto,
|
UserFinanceScopeDto,
|
||||||
UserStatus,
|
UserStatus,
|
||||||
UserUpdatePayload
|
UserUpdatePayload
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
@ -65,25 +65,25 @@ export function exportUsers(query?: PageQuery): Promise<Response> {
|
|||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true });
|
return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoneyScopeCatalogDto {
|
export interface FinanceScopeCatalogDto {
|
||||||
all?: boolean;
|
all?: boolean;
|
||||||
apps?: Array<{ appCode: string; appName?: string }>;
|
apps?: Array<{ appCode: string; appName?: string }>;
|
||||||
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>;
|
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>;
|
||||||
countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>;
|
countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>;
|
||||||
scopes?: UserMoneyScopeDto[];
|
scopes?: UserFinanceScopeDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listUserMoneyScopes(id: EntityId): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
|
export function listUserFinanceScopes(id: EntityId): Promise<{ items: UserFinanceScopeDto[]; total: number }> {
|
||||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserMoneyScopes, { id }));
|
return apiRequest<{ items: UserFinanceScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserFinanceScopes, { id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceUserMoneyScopes(id: EntityId, scopes: UserMoneyScopeDto[]): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
|
export function replaceUserFinanceScopes(id: EntityId, scopes: UserFinanceScopeDto[]): Promise<{ items: UserFinanceScopeDto[]; total: number }> {
|
||||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }, { scopes: UserMoneyScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserMoneyScopes, { id }), {
|
return apiRequest<{ items: UserFinanceScopeDto[]; total: number }, { scopes: UserFinanceScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserFinanceScopes, { id }), {
|
||||||
body: { scopes },
|
body: { scopes },
|
||||||
method: "PUT"
|
method: "PUT"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMoneyScopeCatalog(): Promise<MoneyScopeCatalogDto> {
|
export function getFinanceScopeCatalog(): Promise<FinanceScopeCatalogDto> {
|
||||||
return apiRequest<MoneyScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getMoneyScope));
|
return apiRequest<FinanceScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getFinanceScope));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,27 @@
|
|||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
|
||||||
export function UserFormDrawer({
|
export function UserFormDrawer({
|
||||||
editingUser,
|
editingUser,
|
||||||
|
financeScopeCatalog,
|
||||||
form,
|
form,
|
||||||
moneyScopeCatalog,
|
|
||||||
onClose,
|
onClose,
|
||||||
onMoneyScopeChecked,
|
onFinanceScopeChecked,
|
||||||
onRoleChecked,
|
onRoleChecked,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
open,
|
open,
|
||||||
roles,
|
roles,
|
||||||
setForm
|
setForm,
|
||||||
|
teams = []
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
<form className="form-drawer" onSubmit={onSubmit}>
|
<form className="form-drawer form-drawer--sticky-actions" onSubmit={onSubmit}>
|
||||||
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2>
|
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2>
|
||||||
|
<div className="form-drawer__content">
|
||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section">
|
||||||
<div className="form-drawer__section-title">基础信息</div>
|
<div className="form-drawer__section-title">基础信息</div>
|
||||||
<div className="form-drawer__grid">
|
<div className="form-drawer__grid">
|
||||||
@ -30,7 +33,23 @@ export function UserFormDrawer({
|
|||||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||||
<TextField label="团队" value={form.team} onChange={(event) => setForm({ ...form, team: event.target.value })} />
|
<TextField
|
||||||
|
label="团队"
|
||||||
|
select
|
||||||
|
value={form.teamId ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const teamId = event.target.value;
|
||||||
|
const team = teams.find((item) => String(item.id) === String(teamId));
|
||||||
|
setForm({ ...form, team: team?.name || "", teamId });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">未选择</MenuItem>
|
||||||
|
{teams.map((team) => (
|
||||||
|
<MenuItem key={team.id} value={team.id}>
|
||||||
|
{team.parentName ? `${team.parentName} / ${team.name}` : team.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
{!editingUser ? (
|
{!editingUser ? (
|
||||||
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -38,17 +57,17 @@ export function UserFormDrawer({
|
|||||||
</section>
|
</section>
|
||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section">
|
||||||
<div className="form-drawer__section-title">财务范围</div>
|
<div className="form-drawer__section-title">财务范围</div>
|
||||||
<div className="permission-grid permission-grid--money">
|
<div className="permission-grid permission-grid--finance">
|
||||||
{moneyScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
|
{financeScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
|
||||||
{moneyScopeCatalog?.error ? <span className="permission-check">{moneyScopeCatalog.error}</span> : null}
|
{financeScopeCatalog?.error ? <span className="permission-check">{financeScopeCatalog.error}</span> : null}
|
||||||
{!moneyScopeCatalog?.loading && !moneyScopeCatalog?.error
|
{!financeScopeCatalog?.loading && !financeScopeCatalog?.error
|
||||||
? (moneyScopeCatalog?.apps || []).map((app) => (
|
? (financeScopeCatalog?.apps || []).map((app) => (
|
||||||
<MoneyScopeGroup
|
<FinanceScopeGroup
|
||||||
app={app}
|
app={app}
|
||||||
checkedScopes={form.moneyScopes || []}
|
checkedScopes={form.financeScopes || []}
|
||||||
key={app.appCode}
|
key={app.appCode}
|
||||||
onChecked={onMoneyScopeChecked}
|
onChecked={onFinanceScopeChecked}
|
||||||
regions={(moneyScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
|
regions={(financeScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
@ -79,6 +98,7 @@ export function UserFormDrawer({
|
|||||||
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<div className="form-drawer__actions">
|
<div className="form-drawer__actions">
|
||||||
<Button onClick={onClose}>取消</Button>
|
<Button onClick={onClose}>取消</Button>
|
||||||
<Button type="submit" variant="primary">提交</Button>
|
<Button type="submit" variant="primary">提交</Button>
|
||||||
@ -88,7 +108,7 @@ export function UserFormDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoneyScopeGroup({ app, checkedScopes, onChecked, regions }) {
|
function FinanceScopeGroup({ app, checkedScopes, onChecked, regions }) {
|
||||||
const appCode = app.appCode;
|
const appCode = app.appCode;
|
||||||
const isChecked = (regionId) =>
|
const isChecked = (regionId) =>
|
||||||
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));
|
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));
|
||||||
|
|||||||
48
src/features/users/components/UserFormDrawer.test.jsx
Normal file
48
src/features/users/components/UserFormDrawer.test.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { UserFormDrawer } from "./UserFormDrawer.jsx";
|
||||||
|
|
||||||
|
const baseForm = {
|
||||||
|
financeScopes: [],
|
||||||
|
mfaEnabled: false,
|
||||||
|
name: "zhangsan",
|
||||||
|
password: "",
|
||||||
|
roleIds: [1],
|
||||||
|
teamId: "10",
|
||||||
|
username: "zhangsan"
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseFinanceScopeCatalog = {
|
||||||
|
apps: [{ appCode: "Huwaa", appName: "Huwaa" }],
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
regions: [{ appCode: "Huwaa", name: "菲律宾", regionCode: "PHILIPPINES", regionId: 8 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
test("keeps edit user drawer actions docked outside the scrollable content", () => {
|
||||||
|
render(
|
||||||
|
<UserFormDrawer
|
||||||
|
editingUser={{ id: 3 }}
|
||||||
|
financeScopeCatalog={baseFinanceScopeCatalog}
|
||||||
|
form={baseForm}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onFinanceScopeChecked={vi.fn()}
|
||||||
|
onRoleChecked={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
open
|
||||||
|
roles={[{ id: 1, name: "运维管理员" }]}
|
||||||
|
setForm={vi.fn()}
|
||||||
|
teams={[{ id: 10, name: "运营一部" }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = document.querySelector("form.form-drawer");
|
||||||
|
const content = form.querySelector(".form-drawer__content");
|
||||||
|
const actions = form.querySelector(".form-drawer__actions");
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "编辑用户" })).toBeInTheDocument();
|
||||||
|
expect(form).toHaveClass("form-drawer--sticky-actions");
|
||||||
|
expect(content).toContainElement(screen.getByText("财务范围").closest(".form-drawer__section"));
|
||||||
|
expect(actions).toContainElement(screen.getByRole("button", { name: "取消" }));
|
||||||
|
expect(actions).toContainElement(screen.getByRole("button", { name: "提交" }));
|
||||||
|
});
|
||||||
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