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 { 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 { DatabiHeader } from "./components/DatabiHeader.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 { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||
import { SocialBiDashboard } from "./components/SocialBiDashboard.jsx";
|
||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||
|
||||
const initialFilterOptions = {
|
||||
appOptions: [],
|
||||
appOptions: getDefaultAppOptions(),
|
||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||
};
|
||||
|
||||
export function DatabiApp() {
|
||||
if (isSocialBiPath()) {
|
||||
return <SocialBiDashboard />;
|
||||
}
|
||||
return <DatabiOverviewApp />;
|
||||
}
|
||||
|
||||
function DatabiOverviewApp() {
|
||||
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
|
||||
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
|
||||
const [timeZone, setTimeZone] = useState(initialTimeZone);
|
||||
@ -29,7 +37,6 @@ export function DatabiApp() {
|
||||
const [regionId, setRegionId] = useState("all");
|
||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||
const [viewMode, setViewMode] = useState("report");
|
||||
// 侧边栏目前只有数据报表接入真实业务流,财务和运营保持空组件,避免误触发数据报表请求。
|
||||
const [activeReportSection, setActiveReportSection] = useState("data");
|
||||
const [activeScreen, setActiveScreen] = useState("overview");
|
||||
const [gameId, setGameId] = useState("all");
|
||||
@ -174,7 +181,6 @@ export function DatabiApp() {
|
||||
}, [appCode, countryId, gameId, range, regionId, timeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
// 非数据报表没有筛选控件,提前停止筛选项加载,避免空报表页产生无意义网络请求。
|
||||
if (!isDataReportSection) {
|
||||
return undefined;
|
||||
}
|
||||
@ -182,7 +188,6 @@ export function DatabiApp() {
|
||||
}, [isDataReportSection, loadFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
// 当前数据轮询只属于数据报表;切到财务或运营时清理定时器并保留已有数据,方便切回。
|
||||
if (!isDataReportSection) {
|
||||
return undefined;
|
||||
}
|
||||
@ -254,6 +259,14 @@ export function DatabiApp() {
|
||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||
) : isReportView ? (
|
||||
<ReportOverview
|
||||
grantDrilldownQuery={{
|
||||
appCode,
|
||||
countryId,
|
||||
endMs: rangeEndMs(range, timeZone),
|
||||
regionId,
|
||||
startMs: rangeStartMs(range, timeZone),
|
||||
statTz: timeZone
|
||||
}}
|
||||
loading={showSkeleton}
|
||||
model={model}
|
||||
onMetricTrend={setTrendModalItem}
|
||||
@ -273,9 +286,7 @@ export function DatabiApp() {
|
||||
)}
|
||||
|
||||
{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}
|
||||
{isDataReportSection && activeScreen === "overview" && poolModalOpen ? (
|
||||
<LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} />
|
||||
@ -325,10 +336,17 @@ function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools
|
||||
|
||||
function resolveSelectedAppCode(options, current) {
|
||||
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 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) {
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
function isSocialBiPath() {
|
||||
return window.location.pathname.replace(/\/+$/, "") === "/databi/social";
|
||||
}
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { DatabiApp } from "./DatabiApp.jsx";
|
||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchSocialBiFilterOptions, fetchStatisticsOverview } from "./api.js";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
fetchFilterOptions: vi.fn(),
|
||||
fetchSocialBiFilterOptions: vi.fn(),
|
||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||
fetchStatisticsOverview: vi.fn(),
|
||||
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())
|
||||
}));
|
||||
|
||||
@ -19,14 +26,29 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
|
||||
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 }],
|
||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||
});
|
||||
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(() => {
|
||||
window.history.pushState(null, "", "/");
|
||||
vi.useRealTimers();
|
||||
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({
|
||||
coin_seller_stock_coin: 400_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();
|
||||
});
|
||||
|
||||
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() {
|
||||
await act(async () => {
|
||||
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_BASE_URL } from "../../src/shared/config/env.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 APP_CODE_KEY = "hyapp-admin.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() {
|
||||
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) {
|
||||
const appCode = normalizeAppCode(value) || "lalu";
|
||||
const appCode = normalizeAppCode(value);
|
||||
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
||||
return appCode;
|
||||
}
|
||||
|
||||
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) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
@ -40,11 +54,25 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
|
||||
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 }) {
|
||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
||||
const query = appScopedQuery(appCode);
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
@ -64,7 +92,42 @@ export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endM
|
||||
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 } = {}) {
|
||||
@ -77,6 +140,21 @@ export async function fetchFilterOptions({ appCode } = {}) {
|
||||
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 } = {}) {
|
||||
const response = await fetch(buildURL(path, query), {
|
||||
credentials: "include",
|
||||
@ -145,6 +223,12 @@ function normalizeAppCode(value) {
|
||||
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 }) {
|
||||
const regionItems = normalizeList(regions);
|
||||
const regionOptions = [
|
||||
@ -169,7 +253,7 @@ function normalizeFilterOptions({ apps, countries, regions }) {
|
||||
.map((country) => normalizeCountryOption(country, countryRegionIds))
|
||||
.filter(Boolean)
|
||||
];
|
||||
const appOptions = normalizeList(apps)
|
||||
const apiAppOptions = normalizeList(apps)
|
||||
.map((app) => {
|
||||
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
||||
return {
|
||||
@ -179,6 +263,7 @@ function normalizeFilterOptions({ apps, countries, regions }) {
|
||||
};
|
||||
})
|
||||
.filter((app) => app.code);
|
||||
const appOptions = mergeAppOptions([ALL_APP_OPTION, ...apiAppOptions, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
|
||||
|
||||
return {
|
||||
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) {
|
||||
if (Array.isArray(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");
|
||||
});
|
||||
|
||||
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 { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
||||
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
|
||||
@ -14,6 +15,16 @@ const reportTabs = [
|
||||
{ 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 = [
|
||||
{ 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 },
|
||||
@ -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: "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: "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: "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: "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 },
|
||||
@ -88,7 +101,7 @@ const metricColumns = [
|
||||
{ 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 summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
||||
|
||||
@ -116,7 +129,7 @@ export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLu
|
||||
))}
|
||||
</div>
|
||||
<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 === "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}
|
||||
@ -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 [grantCountryModal, setGrantCountryModal] = useState(null);
|
||||
const [grantUsersModal, setGrantUsersModal] = useState(null);
|
||||
const [grantRecordsModal, setGrantRecordsModal] = useState(null);
|
||||
const toggleRow = useCallback((rowId) => {
|
||||
setExpandedRowIds((current) => {
|
||||
const next = new Set(current);
|
||||
@ -195,17 +211,66 @@ function CountryReport({ loading, rows }) {
|
||||
onToggle: () => toggleRow(row.row_id)
|
||||
};
|
||||
}), [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 (
|
||||
<ReportPanel>
|
||||
<ReportTable
|
||||
columns={countryColumns}
|
||||
context={grantContext}
|
||||
loading={loading}
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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 [sortState, setSortState] = useState(sortableDefault);
|
||||
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(" ")}
|
||||
key={column.key}
|
||||
>
|
||||
{column.render ? column.render(row) : row[column.key]}
|
||||
{column.render ? column.render(row, context) : row[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</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) {
|
||||
return <div className="report-subtable-empty">当前无数据</div>;
|
||||
}
|
||||
@ -398,7 +463,7 @@ function CountryDetailTable({ dateLabel, rows }) {
|
||||
<tr key={reportRowKey(row, index)}>
|
||||
<td>{index + 1}</td>
|
||||
{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>
|
||||
))}
|
||||
@ -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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||
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 { expect, test } from "vitest";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
|
||||
import { createDashboardModel } from "../data/createDashboardModel.js";
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
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");
|
||||
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();
|
||||
|
||||
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(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 realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
||||
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 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 luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
|
||||
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 payoutDistribution = normalizeDistribution(source);
|
||||
const gameRanking = normalizeGameRanking(source);
|
||||
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
|
||||
const reportCountryRows = buildReportCountryRows({
|
||||
countryBreakdown,
|
||||
dailyCountryBreakdown,
|
||||
dailySeries,
|
||||
range,
|
||||
source,
|
||||
});
|
||||
|
||||
return {
|
||||
appCode,
|
||||
businessKpis: [
|
||||
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
|
||||
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "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") })
|
||||
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, {
|
||||
trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin"),
|
||||
}),
|
||||
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, {
|
||||
trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "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,
|
||||
dailyCountryBreakdown,
|
||||
funnel: [
|
||||
{ name: "活跃用户", rate: 1, value: activeUsers },
|
||||
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
||||
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers }
|
||||
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers },
|
||||
],
|
||||
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_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) }
|
||||
{
|
||||
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_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,
|
||||
giftRanking: normalizeGiftRanking(source),
|
||||
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,
|
||||
label: "用户充值 / 新用户充值",
|
||||
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"),
|
||||
label: "用户充值",
|
||||
value: formatMoneyFull(userRecharge),
|
||||
},
|
||||
{
|
||||
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"
|
||||
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,
|
||||
label: "付费用户 / 新增付费用户",
|
||||
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"),
|
||||
label: "付费用户",
|
||||
value: formatNumber(paidUsers),
|
||||
},
|
||||
{
|
||||
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"
|
||||
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: [
|
||||
{ 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_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) }
|
||||
{
|
||||
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_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,
|
||||
payoutDistribution,
|
||||
@ -112,22 +232,54 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
||||
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
||||
revenueSeries,
|
||||
robotGiftKpis: [
|
||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_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") })
|
||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", {
|
||||
trend: metricTrend(dailySeries, "real_room_robot_normal_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: [
|
||||
{ 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: [
|
||||
{ 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, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
||||
{
|
||||
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, "arppu_delta_rate", "arppuDeltaRate")),
|
||||
label: "ARPPU",
|
||||
value: formatMoney(arppu),
|
||||
},
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
function hasAnyKey(source, ...keys) {
|
||||
return keys.some((key) => source?.[key] !== undefined);
|
||||
}
|
||||
|
||||
function readDelta(source, ...keys) {
|
||||
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
||||
for (const key of keys) {
|
||||
@ -204,7 +360,7 @@ function normalizeRevenueSeries(source) {
|
||||
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),
|
||||
mifapay,
|
||||
total
|
||||
total,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -219,26 +375,32 @@ function normalizeRevenueSeries(source) {
|
||||
label: "当前",
|
||||
lineTotal: total,
|
||||
mifapay,
|
||||
total
|
||||
}
|
||||
total,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeCountries(source, countryId) {
|
||||
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
|
||||
const rows = raw || (countryId ? [{
|
||||
active_users: source.active_users,
|
||||
arppu_usd_minor: source.arppu_usd_minor,
|
||||
country: `国家 ${countryId}`,
|
||||
paid_users: source.paid_users,
|
||||
recharge_usd_minor: source.recharge_usd_minor,
|
||||
x: 56,
|
||||
y: 52
|
||||
}] : []);
|
||||
const rows =
|
||||
raw ||
|
||||
(countryId
|
||||
? [
|
||||
{
|
||||
active_users: source.active_users,
|
||||
arppu_usd_minor: source.arppu_usd_minor,
|
||||
country: `国家 ${countryId}`,
|
||||
paid_users: source.paid_users,
|
||||
recharge_usd_minor: source.recharge_usd_minor,
|
||||
x: 56,
|
||||
y: 52,
|
||||
},
|
||||
]
|
||||
: []);
|
||||
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||||
return rows
|
||||
.map((item, index) => ({
|
||||
...normalizeCountryRow(item, index, totalRecharge)
|
||||
...normalizeCountryRow(item, index, totalRecharge),
|
||||
}))
|
||||
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
||||
}
|
||||
@ -252,7 +414,7 @@ function normalizeDailyCountryBreakdown(source) {
|
||||
return rows.map((item, index) => ({
|
||||
...normalizeCountryRow(item, index, totalRecharge),
|
||||
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), {
|
||||
country: "总计",
|
||||
date_label: "全部",
|
||||
row_type: "total"
|
||||
row_type: "total",
|
||||
});
|
||||
if (!isMultiDayRange(range)) {
|
||||
return [
|
||||
totalRow,
|
||||
...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))
|
||||
];
|
||||
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))];
|
||||
}
|
||||
|
||||
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
|
||||
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
|
||||
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();
|
||||
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();
|
||||
|
||||
if (!dayKeys.length) {
|
||||
return [
|
||||
totalRow,
|
||||
...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))
|
||||
];
|
||||
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))];
|
||||
}
|
||||
|
||||
const rows = [totalRow];
|
||||
for (const day of dayKeys) {
|
||||
const dayCountries = dailyRowsByDay.get(day) || [];
|
||||
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
|
||||
const childRows = dayCountries.map((row) => createReportRow(row, {
|
||||
date_label: day,
|
||||
row_type: "country",
|
||||
stat_day: day
|
||||
}));
|
||||
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||
children: childRows,
|
||||
country: "总计",
|
||||
date_label: day,
|
||||
row_type: "date_total",
|
||||
stat_day: day
|
||||
}));
|
||||
const childRows = dayCountries.map((row) =>
|
||||
createReportRow(row, {
|
||||
date_label: day,
|
||||
row_type: "country",
|
||||
stat_day: day,
|
||||
}),
|
||||
);
|
||||
rows.push(
|
||||
createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||
children: childRows,
|
||||
country: "总计",
|
||||
date_label: day,
|
||||
row_type: "date_total",
|
||||
stat_day: day,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@ -308,12 +465,7 @@ function createReportRow(row, extra) {
|
||||
...row,
|
||||
...extra,
|
||||
flag: extra.row_type === "country" ? row.flag : "",
|
||||
row_id: [
|
||||
extra.row_type,
|
||||
extra.stat_day || extra.date_label || "",
|
||||
row.country_id || row.country || "",
|
||||
row.region_id || ""
|
||||
].join(":")
|
||||
row_id: [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_total",
|
||||
"consumed_coin",
|
||||
"output_coin",
|
||||
"game_payout",
|
||||
"game_refund",
|
||||
"game_turnover",
|
||||
@ -359,17 +512,30 @@ function summarizeReportRows(rows) {
|
||||
"salary_usd_minor",
|
||||
"salary_transfer_coin",
|
||||
"super_lucky_gift_payout",
|
||||
"super_lucky_gift_turnover"
|
||||
"super_lucky_gift_turnover",
|
||||
];
|
||||
const summary = fields.reduce((summary, key) => {
|
||||
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
|
||||
summary[key] = sumOptionalField(rows, key);
|
||||
return summary;
|
||||
}, {});
|
||||
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算,不能把国家平均值相加。
|
||||
summary.avg_mic_online_ms = Math.round(ratio(summary.mic_online_ms, summary.mic_online_users));
|
||||
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算;如果下游 App 没有麦上时长字段,保留 null 让报表展示缺失而不是 0。
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 meta = resolveCountryMeta({ code, name: country });
|
||||
const recharge = effectiveRechargeUSDMinor(item);
|
||||
const activeUsers = numberValue(item.active_users);
|
||||
const paidUsers = numberValue(item.paid_users);
|
||||
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
|
||||
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
||||
const gamePayout = readNumber(item, "game_payout", "gamePayout");
|
||||
const gameRefund = readNumber(item, "game_refund", "gameRefund");
|
||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover - gamePayout - gameRefund);
|
||||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover - luckyGiftPayout);
|
||||
const activeUsers = readOptionalNumber(item, "active_users", "activeUsers");
|
||||
const paidUsers = readOptionalNumber(item, "paid_users", "paidUsers");
|
||||
const rechargeUsers = readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
||||
const gameTurnover = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||
const gameRefund = readOptionalNumber(item, "game_refund", "gameRefund") ?? 0;
|
||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover === null || gamePayout === null ? null : gameTurnover - gamePayout - gameRefund);
|
||||
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 superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
|
||||
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);
|
||||
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
||||
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
|
||||
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (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 {
|
||||
active_users: activeUsers,
|
||||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
||||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
||||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
||||
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||
avg_recharge_usd_minor: readOptionalNumber(item, "avg_recharge_usd_minor", "avgRechargeUsdMinor"),
|
||||
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_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"),
|
||||
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
|
||||
consumed_coin: consumedCoin,
|
||||
consume_output_delta: consumeOutputDelta,
|
||||
consume_output_ratio: consumeOutputRatio,
|
||||
country: meta?.label || country,
|
||||
country_id: countryId,
|
||||
country_code: code || meta?.code || "",
|
||||
flag: countryFlag(code || meta?.code),
|
||||
game_payout: gamePayout,
|
||||
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_turnover: gameTurnover,
|
||||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||||
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
google_recharge_usd_minor: readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||||
lucky_gift_payout: luckyGiftPayout,
|
||||
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,
|
||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||
mic_online_ms: micOnlineMS,
|
||||
mic_online_users: micOnlineUsers,
|
||||
mifapay_recharge_usd_minor: readNumber(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),
|
||||
mifapay_recharge_usd_minor: readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
|
||||
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||
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"),
|
||||
recharge_usd_minor: recharge,
|
||||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
||||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
||||
: ratio(rechargeUsers, activeUsers),
|
||||
recharge_conversion_rate: hasAnyKey(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||
? readOptionalNumber(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||
: rechargeUsers === null || activeUsers === null
|
||||
? null
|
||||
: ratio(rechargeUsers, activeUsers),
|
||||
recharge_users: rechargeUsers,
|
||||
region_id: numberValue(item.region_id ?? item.regionId),
|
||||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||
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_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,
|
||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveRechargeUSDMinor(source) {
|
||||
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, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
|
||||
return Math.max(direct, channelTotal);
|
||||
@ -472,56 +653,70 @@ function normalizeDailySeries(source) {
|
||||
return [];
|
||||
}
|
||||
return source.daily_series.map((item) => {
|
||||
const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
||||
const mifapay = readNumber(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 googleMetric = readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
||||
const mifapayMetric = readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
|
||||
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 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 gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
|
||||
const gameProfit = readNumber(item, "game_profit", "gameProfit");
|
||||
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||
const gameTurnover = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit");
|
||||
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 micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
|
||||
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (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 {
|
||||
active_users: readNumber(item, "active_users", "activeUsers"),
|
||||
arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||
active_users: readOptionalNumber(item, "active_users", "activeUsers"),
|
||||
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||
avg_mic_online_ms: avgMicOnlineMS,
|
||||
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_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"),
|
||||
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_turnover: gameTurnover,
|
||||
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
google,
|
||||
google_recharge_usd_minor: googleMetric,
|
||||
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
|
||||
lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
|
||||
lucky_gift_payout: luckyGiftPayout,
|
||||
lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
|
||||
lucky_gift_profit: luckyGiftProfit,
|
||||
lucky_gift_turnover: luckyGiftTurnover,
|
||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||
mic_online_ms: micOnlineMS,
|
||||
mic_online_users: micOnlineUsers,
|
||||
mifapay,
|
||||
new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"),
|
||||
new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||
new_users: readNumber(item, "new_users", "newUsers"),
|
||||
paid_users: readNumber(item, "paid_users", "paidUsers"),
|
||||
mifapay_recharge_usd_minor: mifapayMetric,
|
||||
new_paid_users: readOptionalNumber(item, "new_paid_users", "newPaidUsers"),
|
||||
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||
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"),
|
||||
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_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
||||
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") {
|
||||
const data = series.map((item) => ({
|
||||
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 }] : [];
|
||||
}
|
||||
@ -539,7 +734,7 @@ function normalizeGiftRanking(source) {
|
||||
return source.gift_ranking.map((item) => ({
|
||||
name: item.name || item.gift_name || item.gift_id || "-",
|
||||
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 [];
|
||||
@ -554,7 +749,7 @@ function normalizeDistribution(source) {
|
||||
}
|
||||
return [
|
||||
{ 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() || "-",
|
||||
profit,
|
||||
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));
|
||||
}
|
||||
|
||||
function summarizeLuckyGiftPools(items) {
|
||||
return items.reduce((summary, item) => ({
|
||||
payout: summary.payout + item.payout,
|
||||
profit: summary.profit + item.profit,
|
||||
turnover: summary.turnover + item.turnover
|
||||
}), { payout: 0, profit: 0, turnover: 0 });
|
||||
return items.reduce(
|
||||
(summary, item) => ({
|
||||
payout: summary.payout + item.payout,
|
||||
profit: summary.profit + item.profit,
|
||||
turnover: summary.turnover + item.turnover,
|
||||
}),
|
||||
{ payout: 0, profit: 0, turnover: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function firstArray(...values) {
|
||||
@ -598,7 +796,7 @@ function normalizeGameRanking(source) {
|
||||
game_name: item.game_name || item.gameName || item.name || "",
|
||||
platform_code: item.platform_code || item.platformCode || "",
|
||||
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 [];
|
||||
|
||||
@ -2,19 +2,22 @@ import { expect, test } from "vitest";
|
||||
import { createDashboardModel } from "./createDashboardModel.js";
|
||||
|
||||
test("uses lucky gift pool breakdown to aggregate business metrics", () => {
|
||||
const model = createDashboardModel({
|
||||
lucky_gift_pools: [
|
||||
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
|
||||
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 }
|
||||
],
|
||||
lucky_gift_payout: 1,
|
||||
lucky_gift_profit: 1,
|
||||
lucky_gift_turnover: 1
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
lucky_gift_pools: [
|
||||
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
|
||||
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 },
|
||||
],
|
||||
lucky_gift_payout: 1,
|
||||
lucky_gift_profit: 1,
|
||||
lucky_gift_turnover: 1,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(model.luckyGiftPools).toEqual([
|
||||
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("350");
|
||||
@ -23,48 +26,61 @@ 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", () => {
|
||||
const model = createDashboardModel({
|
||||
coin_seller_recharge_usd_minor: 200,
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
salary_transfer_coin: 88_000,
|
||||
country_breakdown: [
|
||||
{
|
||||
coin_seller_recharge_usd_minor: 200,
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
country: "阿富汗",
|
||||
google_recharge_usd_minor: 150,
|
||||
recharge_usd_minor: 150,
|
||||
salary_transfer_coin: 88_000
|
||||
}
|
||||
],
|
||||
daily_series: [
|
||||
{
|
||||
coin_seller: 200,
|
||||
google: 150,
|
||||
label: "当前",
|
||||
recharge_usd_minor: 150
|
||||
}
|
||||
],
|
||||
google_recharge_usd_minor: 150,
|
||||
recharge_usd_minor: 150
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
coin_seller_recharge_usd_minor: 200,
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
salary_transfer_coin: 88_000,
|
||||
country_breakdown: [
|
||||
{
|
||||
coin_seller_recharge_usd_minor: 200,
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
country: "阿富汗",
|
||||
google_recharge_usd_minor: 150,
|
||||
recharge_usd_minor: 150,
|
||||
salary_transfer_coin: 88_000,
|
||||
},
|
||||
],
|
||||
daily_series: [
|
||||
{
|
||||
coin_seller: 200,
|
||||
google: 150,
|
||||
label: "当前",
|
||||
recharge_usd_minor: 150,
|
||||
},
|
||||
],
|
||||
google_recharge_usd_minor: 150,
|
||||
recharge_usd_minor: 150,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(metricValue(model, "总充值")).toBe("$4");
|
||||
expect(metricValue(model, "币商出货金币")).toBe("160,000");
|
||||
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", () => {
|
||||
const model = createDashboardModel({
|
||||
active_users: 80,
|
||||
new_users: 12,
|
||||
new_users_delta_rate: 0.24,
|
||||
paid_users: 8,
|
||||
recharge_users: 7
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
active_users: 80,
|
||||
new_users: 12,
|
||||
new_users_delta_rate: 0.24,
|
||||
paid_users: 8,
|
||||
recharge_users: 7,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(metricValue(model, "新增用户数")).toBe("12");
|
||||
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", () => {
|
||||
const model = createDashboardModel({
|
||||
real_room_robot_gift_room_count: 2,
|
||||
real_room_robot_lucky_gift_coin: 200,
|
||||
real_room_robot_normal_gift_coin: 100,
|
||||
real_room_robot_super_gift_coin: 300
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
real_room_robot_gift_room_count: 2,
|
||||
real_room_robot_lucky_gift_coin: 200,
|
||||
real_room_robot_normal_gift_coin: 100,
|
||||
real_room_robot_super_gift_coin: 300,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(metricValue(model, "真人房机器人普通礼物")).toBe("100");
|
||||
expect(metricValue(model, "真人房机器人幸运礼物")).toBe("200");
|
||||
@ -88,130 +107,205 @@ test("adds real-room robot gift metrics to a separate card row", () => {
|
||||
});
|
||||
|
||||
test("uses active users as payer and recharge conversion denominator", () => {
|
||||
const model = createDashboardModel({
|
||||
active_users: 80,
|
||||
country_breakdown: [
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
recharge_usd_minor: 500
|
||||
}
|
||||
],
|
||||
new_users: 4,
|
||||
paid_users: 8,
|
||||
recharge_users: 6
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
active_users: 80,
|
||||
country_breakdown: [
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
recharge_usd_minor: 500,
|
||||
},
|
||||
],
|
||||
new_users: 4,
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
},
|
||||
{ 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.countryBreakdown[0]).toEqual(expect.objectContaining({
|
||||
payer_rate: 0.4,
|
||||
recharge_conversion_rate: 0.3
|
||||
}));
|
||||
expect(model.countryBreakdown[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
payer_rate: 0.4,
|
||||
recharge_conversion_rate: 0.3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses registration count instead of legacy visitors in country breakdown", () => {
|
||||
const model = createDashboardModel({
|
||||
country_breakdown: [
|
||||
{
|
||||
country: "菲律宾",
|
||||
new_users: 12,
|
||||
visitors: 99
|
||||
}
|
||||
]
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
country_breakdown: [
|
||||
{
|
||||
country: "菲律宾",
|
||||
new_users: 12,
|
||||
visitors: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
||||
registrations: 12
|
||||
}));
|
||||
expect(model.countryBreakdown[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
registrations: 12,
|
||||
}),
|
||||
);
|
||||
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
||||
});
|
||||
|
||||
test("builds report rows with range total, daily totals, and daily country rows", () => {
|
||||
const model = createDashboardModel({
|
||||
country_breakdown: [
|
||||
{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }
|
||||
],
|
||||
daily_country_breakdown: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
stat_day: "2026-06-05",
|
||||
super_lucky_gift_turnover: 80,
|
||||
super_lucky_gift_payout: 20
|
||||
},
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
recharge_usd_minor: 200,
|
||||
stat_day: "2026-06-06"
|
||||
}
|
||||
],
|
||||
daily_series: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
stat_day: "2026-06-05"
|
||||
},
|
||||
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" }
|
||||
],
|
||||
recharge_usd_minor: 300
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
country_breakdown: [{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }],
|
||||
daily_country_breakdown: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
stat_day: "2026-06-05",
|
||||
super_lucky_gift_turnover: 80,
|
||||
super_lucky_gift_payout: 20,
|
||||
},
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
recharge_usd_minor: 200,
|
||||
stat_day: "2026-06-06",
|
||||
},
|
||||
],
|
||||
daily_series: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
stat_day: "2026-06-05",
|
||||
},
|
||||
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" },
|
||||
],
|
||||
recharge_usd_minor: 300,
|
||||
},
|
||||
{ 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[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
||||
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
country: "总计",
|
||||
date_label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900
|
||||
}));
|
||||
expect(model.reportCountryRows[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
consume_output_delta: 180,
|
||||
consume_output_ratio: 0.4,
|
||||
country: "总计",
|
||||
date_label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900,
|
||||
}),
|
||||
);
|
||||
expect(model.reportCountryRows[1].children).toHaveLength(1);
|
||||
expect(model.reportCountryRows[1].children[0]).toEqual(expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
country: "巴西",
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
super_lucky_gift_profit: 60
|
||||
}));
|
||||
expect(model.reportCountryRows[1].children[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
consume_output_delta: 180,
|
||||
consume_output_ratio: 0.4,
|
||||
country: "巴西",
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
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) {
|
||||
|
||||
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 "./tables.css";
|
||||
@import "./report.css";
|
||||
@import "./social-bi.css";
|
||||
@import "./responsive.css";
|
||||
|
||||
@ -611,6 +611,39 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
max-width: 220px;
|
||||
@ -711,6 +744,241 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="refresh" content="0; url=/finance/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HYApp 财务系统</title>
|
||||
<title>HYApp 财务系统跳转</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="money-root"></div>
|
||||
<script type="module" src="/money/src/main.jsx"></script>
|
||||
<a href="/finance/">进入财务系统</a>
|
||||
</body>
|
||||
</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 styles from "./ErrorBoundary.module.css";
|
||||
|
||||
export class ErrorBoundary extends Component {
|
||||
state = { error: null };
|
||||
const ASSET_RELOAD_STORAGE_PREFIX = "hyapp-admin:asset-reload:";
|
||||
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) {
|
||||
return { error };
|
||||
export function isRecoverableAssetLoadError(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 = () => {
|
||||
this.setState({ error: null });
|
||||
this.setState({ error: null, recovering: false });
|
||||
};
|
||||
|
||||
reload = () => {
|
||||
@ -21,6 +113,17 @@ export class ErrorBoundary extends Component {
|
||||
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 (
|
||||
<main className={styles.root}>
|
||||
<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) {
|
||||
return fallbackMenus;
|
||||
}
|
||||
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
||||
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation);
|
||||
}, [backendMenus, can]);
|
||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ const deprecatedMenuCodes = new Set(["services"]);
|
||||
const menuLabelOverrides = {
|
||||
system: "后台设置",
|
||||
"system-users": "后台用户",
|
||||
"system-teams": "团队配置",
|
||||
};
|
||||
|
||||
const iconMap = {
|
||||
@ -133,6 +134,7 @@ export const fallbackNavigation = [
|
||||
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
|
||||
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
|
||||
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("logs-login", { icon: LoginOutlined }),
|
||||
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
||||
routeNavItem("system-teams", { icon: GroupsOutlined }),
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -340,9 +343,10 @@ export function filterNavigationByPermission(items = [], can) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
||||
export function mergeNavigationItems(primaryItems = [], fallbackItems = [], orderItems = fallbackNavigation) {
|
||||
const merged = primaryItems.map((item) => ({ ...item }));
|
||||
const itemByCode = new Map(merged.map((item) => [item.code, item]));
|
||||
const orderItemByCode = new Map(orderItems.map((item) => [item.code, item]));
|
||||
|
||||
fallbackItems.forEach((fallbackItem) => {
|
||||
const existing = itemByCode.get(fallbackItem.code);
|
||||
@ -352,11 +356,15 @@ export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
||||
}
|
||||
|
||||
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 = []) {
|
||||
|
||||
@ -175,6 +175,37 @@ describe("navigation menu helpers", () => {
|
||||
"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) {
|
||||
|
||||
@ -6,6 +6,9 @@ export const PERMISSIONS = {
|
||||
userStatus: "user:status",
|
||||
userResetPassword: "user:reset-password",
|
||||
userExport: "user:export",
|
||||
teamView: "team:view",
|
||||
teamCreate: "team:create",
|
||||
teamUpdate: "team:update",
|
||||
countryView: "country:view",
|
||||
countryCreate: "country:create",
|
||||
countryUpdate: "country:update",
|
||||
@ -38,8 +41,18 @@ export const PERMISSIONS = {
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||
moneyView: "money:view",
|
||||
moneyPaymentLinkCreate: "money:payment-link:create",
|
||||
hostWithdrawalView: "host-withdrawal:view",
|
||||
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",
|
||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||
coinAdjustmentView: "coin-adjustment:view",
|
||||
@ -55,6 +68,7 @@ export const PERMISSIONS = {
|
||||
paymentProductUpdate: "payment-product:update",
|
||||
paymentProductDelete: "payment-product:delete",
|
||||
paymentTemporaryLinkView: "payment-temporary-link:view",
|
||||
paymentTemporaryLinkCreate: "payment-temporary-link:create",
|
||||
paymentThirdPartyView: "payment-third-party:view",
|
||||
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||
gameView: "game:view",
|
||||
@ -187,6 +201,7 @@ export const MENU_CODES = {
|
||||
overview: "overview",
|
||||
system: "system",
|
||||
systemUsers: "system-users",
|
||||
systemTeams: "system-teams",
|
||||
systemRoles: "system-roles",
|
||||
systemMenus: "system-menus",
|
||||
logs: "logs",
|
||||
@ -258,6 +273,7 @@ export const MENU_CODES = {
|
||||
hostAgencyPolicy: "host-agency-policy",
|
||||
hostSalarySettlement: "host-salary-settlement",
|
||||
hostOrgCoinSellers: "host-org-coin-sellers",
|
||||
hostWithdrawals: "host-withdrawals",
|
||||
payment: "payment",
|
||||
paymentBillList: "payment-bill-list",
|
||||
paymentRechargeProducts: "payment-recharge-products",
|
||||
|
||||
@ -29,6 +29,7 @@ import { roomRoutes } from "@/features/rooms/routes.js";
|
||||
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
||||
import { roomTurnoverRewardRoutes } from "@/features/room-turnover-reward/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 { usersRoutes } from "@/features/users/routes.js";
|
||||
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
||||
@ -71,6 +72,7 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...hostAgencyPolicyRoutes,
|
||||
...hostOrgRoutes,
|
||||
...rolesRoutes,
|
||||
...teamsRoutes,
|
||||
...menusRoutes,
|
||||
...logsRoutes,
|
||||
];
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
|
||||
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@ -16,6 +16,7 @@ import type {
|
||||
PrettyDisplayIDGenerationBatchDto,
|
||||
PrettyDisplayIDPoolDto,
|
||||
PrettyDisplayIDPoolPayload,
|
||||
RecyclePrettyDisplayIDPayload,
|
||||
SetPrettyDisplayIDStatusPayload,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@ -180,6 +181,21 @@ export async function grantPrettyDisplayID(
|
||||
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(
|
||||
prettyId: EntityId,
|
||||
payload: SetPrettyDisplayIDStatusPayload,
|
||||
@ -614,7 +630,7 @@ function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) {
|
||||
return undefined;
|
||||
}
|
||||
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 {
|
||||
@ -692,7 +708,7 @@ function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto
|
||||
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
|
||||
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
|
||||
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,
|
||||
createdByAdminId,
|
||||
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
|
||||
@ -748,3 +764,12 @@ function normalizeAdminGrantPrettyDisplayIDResult(
|
||||
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 { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createPrettyDisplayIDPool,
|
||||
@ -8,6 +9,7 @@ import {
|
||||
grantPrettyDisplayID,
|
||||
listPrettyDisplayIDPools,
|
||||
listPrettyDisplayIDs,
|
||||
recyclePrettyDisplayID,
|
||||
setPrettyDisplayIDStatus,
|
||||
updatePrettyDisplayIDPool,
|
||||
} from "@/features/app-users/api";
|
||||
@ -36,6 +38,7 @@ const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" }
|
||||
|
||||
export function usePrettyIdsPage() {
|
||||
const abilities = useAppUserAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [poolPage, setPoolPage] = useState(1);
|
||||
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) => {
|
||||
setPoolStatus(value);
|
||||
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
||||
@ -333,6 +356,7 @@ export function usePrettyIdsPage() {
|
||||
reloadAll,
|
||||
resetIDFilters,
|
||||
resetPoolFilters,
|
||||
recyclePrettyID,
|
||||
setActiveTab,
|
||||
setGenerateForm,
|
||||
setGrantForm,
|
||||
@ -347,3 +371,10 @@ export function usePrettyIdsPage() {
|
||||
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 AssignmentReturnOutlined from "@mui/icons-material/AssignmentReturnOutlined";
|
||||
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
@ -365,27 +366,43 @@ function idColumns(page) {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(96px, 0.5fr)",
|
||||
render: (item) =>
|
||||
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
|
||||
render: (item) => {
|
||||
const showStatus = page.abilities.canPrettyUpdate && canChangePrettyStatus(item);
|
||||
const showRecycle = page.abilities.canPrettyUpdate && canRecyclePrettyID(item);
|
||||
if (!showStatus && !showRecycle) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<div className={styles.rowActions}>
|
||||
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
{showStatus ? (
|
||||
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{showRecycle ? (
|
||||
<ActionIcon
|
||||
disabled={page.loadingAction === `recycle-${item.prettyId}`}
|
||||
label="回收"
|
||||
tone="danger"
|
||||
onClick={() => page.recyclePrettyID(item)}
|
||||
>
|
||||
<AssignmentReturnOutlined fontSize="small" />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function AssignedUserCell({ item }) {
|
||||
const assignedUserId = formatEntityID(item.assignedUserId);
|
||||
if (!item.assignedUserId) {
|
||||
const user = assignedUser(item);
|
||||
if (!user) {
|
||||
return <span className={styles.meta}>-</span>;
|
||||
}
|
||||
|
||||
return <AdminUserIdentity openInAppUserDetail rows={[assignedUserId]} user={assignedUser(item)} />;
|
||||
return <AdminUserIdentity openInAppUserDetail user={user} />;
|
||||
}
|
||||
|
||||
function OperatorCell({ item }) {
|
||||
@ -416,12 +433,16 @@ function PrettyIDTimeCell({ item }) {
|
||||
}
|
||||
|
||||
function assignedUser(item) {
|
||||
const user = item.assignedUser || null;
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(item.assignedUser || {}),
|
||||
displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId,
|
||||
...user,
|
||||
displayUserId: user.displayUserId || user.defaultDisplayUserId,
|
||||
prettyDisplayUserId: item.displayUserId,
|
||||
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 (
|
||||
<Tooltip arrow title={label}>
|
||||
<span>
|
||||
<IconButton label={label} onClick={onClick}>
|
||||
<IconButton label={label} {...props}>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
@ -696,16 +717,16 @@ function ruleLabel(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) {
|
||||
const assigned = item.assignedUserId;
|
||||
const unassigned =
|
||||
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
||||
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" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("777777").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||
expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.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("释放 2026-06-27 01:00:00")).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText("回收").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("pretty id page can switch to pool tab", () => {
|
||||
@ -107,6 +110,7 @@ function pageFixture(patch = {}) {
|
||||
reloadAll: vi.fn(),
|
||||
resetIDFilters: vi.fn(),
|
||||
resetPoolFilters: vi.fn(),
|
||||
recyclePrettyID: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setGenerateForm: vi.fn(),
|
||||
setGrantForm: vi.fn(),
|
||||
@ -141,7 +145,7 @@ function prettyIdFixture() {
|
||||
return {
|
||||
assignedAtMs: 1782499200000,
|
||||
assignedUser: {
|
||||
avatar: "",
|
||||
avatar: "https://cdn.example/owner-a.png",
|
||||
defaultDisplayUserId: "10001",
|
||||
displayUserId: "777777",
|
||||
userId: "9001",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
adminAddAgencyHost,
|
||||
createBDLeader,
|
||||
createCountry,
|
||||
createCoinSeller,
|
||||
@ -101,6 +102,11 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
rechargeAmount: "1.25",
|
||||
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" });
|
||||
|
||||
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 [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[11];
|
||||
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("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(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
|
||||
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(agencyDeleteInit?.method).toBe("POST");
|
||||
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";
|
||||
import type {
|
||||
AgencyDto,
|
||||
AgencyHostAddPayload,
|
||||
AgencyJoinEnabledPayload,
|
||||
AgencyStatusPayload,
|
||||
ApiList,
|
||||
@ -29,6 +30,7 @@ import type {
|
||||
CreateManagerPayload,
|
||||
EntityId,
|
||||
HostCommandPayload,
|
||||
HostWithdrawalDto,
|
||||
HostProfileDto,
|
||||
ManagerDto,
|
||||
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>> {
|
||||
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
|
||||
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> {
|
||||
const endpoint = API_ENDPOINTS.closeAgency;
|
||||
return apiRequest<AgencyDto, HostCommandPayload>(
|
||||
|
||||
@ -3,11 +3,18 @@ import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.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 { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.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 = () => ({
|
||||
commandId: makeCommandId("agency"),
|
||||
@ -17,6 +24,11 @@ const emptyAgencyForm = () => ({
|
||||
parentBdUserId: "",
|
||||
reason: "",
|
||||
});
|
||||
const emptyAgencyHostForm = () => ({
|
||||
commandId: makeCommandId("agency-host"),
|
||||
targetUserId: "",
|
||||
reason: "",
|
||||
});
|
||||
const pageSize = 50;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
|
||||
@ -38,6 +50,8 @@ export function useHostAgenciesPage() {
|
||||
const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set());
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeAgency, setActiveAgency] = useState(null);
|
||||
const [agencyHostForm, setAgencyHostForm] = useState(emptyAgencyHostForm);
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
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) => {
|
||||
if (!abilities.canStatus || !agency?.agencyId) {
|
||||
return;
|
||||
@ -230,7 +269,9 @@ export function useHostAgenciesPage() {
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
activeAgency,
|
||||
agencyForm,
|
||||
agencyHostForm,
|
||||
changeOwnerFilter,
|
||||
changeParentBdFilter,
|
||||
changeQuery,
|
||||
@ -250,15 +291,21 @@ export function useHostAgenciesPage() {
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
closeAction: () => setActiveAction(""),
|
||||
closeAction: () => {
|
||||
setActiveAction("");
|
||||
setActiveAgency(null);
|
||||
},
|
||||
openAgencyForm: () => setActiveAction("agency"),
|
||||
openAgencyHostForm,
|
||||
removeAgencyFromList,
|
||||
setAgencyForm,
|
||||
setAgencyHostForm,
|
||||
setPage,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
status,
|
||||
submitAgency,
|
||||
submitAgencyHost,
|
||||
changeSort,
|
||||
toggleAgencyStatusFromList,
|
||||
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 DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -99,6 +100,7 @@ export function HostAgenciesPage() {
|
||||
const page = useHostAgenciesPage();
|
||||
const navigate = useNavigate();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const agencyHostForm = page.agencyHostForm || { reason: "", targetUserId: "" };
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const openAgencyHosts = (agency) => {
|
||||
@ -245,6 +247,35 @@ export function HostAgenciesPage() {
|
||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -312,6 +343,16 @@ function AgencyJoinSwitch({ item, page }) {
|
||||
function AgencyActions({ item, page }) {
|
||||
return (
|
||||
<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 ? (
|
||||
<SalaryWalletAdjustButton
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export function useHostWithdrawalAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canView: can(PERMISSIONS.hostWithdrawalView),
|
||||
};
|
||||
}
|
||||
|
||||
@ -65,4 +65,12 @@ export const hostOrgRoutes = [
|
||||
path: "/host/coin-sellers",
|
||||
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,
|
||||
});
|
||||
|
||||
export const agencyHostAddSchema = commandBaseSchema.extend({
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const agencyCloseSchema = commandBaseSchema.extend({
|
||||
agencyId: entityIdSchema,
|
||||
});
|
||||
@ -211,6 +215,7 @@ export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||
export type AgencyHostAddForm = z.infer<typeof agencyHostAddSchema>;
|
||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||
export type CountryCreateForm = z.infer<typeof countryCreateSchema>;
|
||||
|
||||
@ -19,6 +19,7 @@ const emptyData = { permissions: [], roles: [] };
|
||||
const permissionGroupDefinitions = [
|
||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||
{ key: "users", prefixes: ["user"], title: "后台用户" },
|
||||
{ key: "teams", prefixes: ["team"], title: "团队配置" },
|
||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||
{ 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 { setAccessToken } from "@/shared/api/request";
|
||||
import { getMoneyScopeCatalog, listUserMoneyScopes, listUsers, replaceUserMoneyScopes, updateUserStatus } from "./api";
|
||||
import { getFinanceScopeCatalog, listUserFinanceScopes, listUsers, replaceUserFinanceScopes, updateUserStatus } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
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 } })))
|
||||
);
|
||||
|
||||
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];
|
||||
|
||||
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_size=10");
|
||||
expect(String(url)).toContain("status=active");
|
||||
expect(String(url)).toContain("team_id=2");
|
||||
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" });
|
||||
});
|
||||
|
||||
test("money scope APIs use generated endpoint paths", async () => {
|
||||
test("finance scope APIs use generated endpoint paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { apps: [], items: [], total: 0 } })))
|
||||
);
|
||||
|
||||
await listUserMoneyScopes(9);
|
||||
await replaceUserMoneyScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
||||
await getMoneyScopeCatalog();
|
||||
await listUserFinanceScopes(9);
|
||||
await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]);
|
||||
await getFinanceScopeCatalog();
|
||||
|
||||
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(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(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");
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
PageQuery,
|
||||
ResetPasswordResultDto,
|
||||
UserFormPayload,
|
||||
UserMoneyScopeDto,
|
||||
UserFinanceScopeDto,
|
||||
UserStatus,
|
||||
UserUpdatePayload
|
||||
} from "@/shared/api/types";
|
||||
@ -65,25 +65,25 @@ export function exportUsers(query?: PageQuery): Promise<Response> {
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true });
|
||||
}
|
||||
|
||||
export interface MoneyScopeCatalogDto {
|
||||
export interface FinanceScopeCatalogDto {
|
||||
all?: boolean;
|
||||
apps?: Array<{ appCode: string; appName?: string }>;
|
||||
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: 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 }> {
|
||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserMoneyScopes, { id }));
|
||||
export function listUserFinanceScopes(id: EntityId): Promise<{ items: UserFinanceScopeDto[]; total: number }> {
|
||||
return apiRequest<{ items: UserFinanceScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserFinanceScopes, { id }));
|
||||
}
|
||||
|
||||
export function replaceUserMoneyScopes(id: EntityId, scopes: UserMoneyScopeDto[]): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
|
||||
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }, { scopes: UserMoneyScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserMoneyScopes, { id }), {
|
||||
export function replaceUserFinanceScopes(id: EntityId, scopes: UserFinanceScopeDto[]): Promise<{ items: UserFinanceScopeDto[]; total: number }> {
|
||||
return apiRequest<{ items: UserFinanceScopeDto[]; total: number }, { scopes: UserFinanceScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserFinanceScopes, { id }), {
|
||||
body: { scopes },
|
||||
method: "PUT"
|
||||
});
|
||||
}
|
||||
|
||||
export function getMoneyScopeCatalog(): Promise<MoneyScopeCatalogDto> {
|
||||
return apiRequest<MoneyScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getMoneyScope));
|
||||
export function getFinanceScopeCatalog(): Promise<FinanceScopeCatalogDto> {
|
||||
return apiRequest<FinanceScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getFinanceScope));
|
||||
}
|
||||
|
||||
@ -1,84 +1,104 @@
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
|
||||
export function UserFormDrawer({
|
||||
editingUser,
|
||||
financeScopeCatalog,
|
||||
form,
|
||||
moneyScopeCatalog,
|
||||
onClose,
|
||||
onMoneyScopeChecked,
|
||||
onFinanceScopeChecked,
|
||||
onRoleChecked,
|
||||
onSubmit,
|
||||
open,
|
||||
roles,
|
||||
setForm
|
||||
setForm,
|
||||
teams = []
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={Boolean(editingUser)}
|
||||
label="账号"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: 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 })} />
|
||||
{!editingUser ? (
|
||||
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">财务范围</div>
|
||||
<div className="permission-grid permission-grid--money">
|
||||
{moneyScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
|
||||
{moneyScopeCatalog?.error ? <span className="permission-check">{moneyScopeCatalog.error}</span> : null}
|
||||
{!moneyScopeCatalog?.loading && !moneyScopeCatalog?.error
|
||||
? (moneyScopeCatalog?.apps || []).map((app) => (
|
||||
<MoneyScopeGroup
|
||||
app={app}
|
||||
checkedScopes={form.moneyScopes || []}
|
||||
key={app.appCode}
|
||||
onChecked={onMoneyScopeChecked}
|
||||
regions={(moneyScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={Boolean(editingUser)}
|
||||
label="账号"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
/>
|
||||
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: 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 ? (
|
||||
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">财务范围</div>
|
||||
<div className="permission-grid permission-grid--finance">
|
||||
{financeScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
|
||||
{financeScopeCatalog?.error ? <span className="permission-check">{financeScopeCatalog.error}</span> : null}
|
||||
{!financeScopeCatalog?.loading && !financeScopeCatalog?.error
|
||||
? (financeScopeCatalog?.apps || []).map((app) => (
|
||||
<FinanceScopeGroup
|
||||
app={app}
|
||||
checkedScopes={form.financeScopes || []}
|
||||
key={app.appCode}
|
||||
onChecked={onFinanceScopeChecked}
|
||||
regions={(financeScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">角色</div>
|
||||
<div className="permission-grid">
|
||||
{roles.map((role) => (
|
||||
<label className="permission-check" key={role.id}>
|
||||
<input
|
||||
checked={form.roleIds.includes(role.id)}
|
||||
onChange={(event) => onRoleChecked(role.id, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">角色</div>
|
||||
<div className="permission-grid">
|
||||
{roles.map((role) => (
|
||||
<label className="permission-check" key={role.id}>
|
||||
<input
|
||||
checked={form.roleIds.includes(role.id)}
|
||||
onChange={(event) => onRoleChecked(role.id, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">安全设置</div>
|
||||
<AdminSwitch
|
||||
checked={form.mfaEnabled}
|
||||
checkedLabel="开启"
|
||||
label="MFA 状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
||||
/>
|
||||
</section>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">安全设置</div>
|
||||
<AdminSwitch
|
||||
checked={form.mfaEnabled}
|
||||
checkedLabel="开启"
|
||||
label="MFA 状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={onClose}>取消</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 isChecked = (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