Compare commits

...

9 Commits

Author SHA1 Message Date
zhx
c836d0f702 Handle asset preload failures with auto reload 2026-07-02 14:49:34 +08:00
zhx
18ad13563b 主播提现列表 2026-07-02 14:21:32 +08:00
zhx
8fe2efb344 更改相关接口 2026-07-02 11:45:48 +08:00
zhx
91a7c784c9 新BI系统 2026-07-02 10:54:00 +08:00
zhx
b0b1a26ae8 修复靓号 2026-06-30 20:50:34 +08:00
zhx
810ee6721c 用户靓号收回 2026-06-30 16:01:15 +08:00
zhx
b81c2edf43 数据大屏 2026-06-30 15:17:09 +08:00
zhx
a952b4262e 更新数据大屏金币统计列 2026-06-30 14:41:06 +08:00
zhx
f5e1a20dd9 数据大屏口径修正 2026-06-30 14:11:55 +08:00
118 changed files with 11907 additions and 5244 deletions

File diff suppressed because it is too large Load Diff

12
databi/social/index.html Normal file
View 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>

View File

@ -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";
}

View File

@ -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) {

View File

@ -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
View 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
});
}

View File

@ -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("");
});

View File

@ -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 "--";

View File

@ -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"
})));
});

File diff suppressed because it is too large Load Diff

View File

@ -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 [];

View File

@ -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) {

View 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 };
}

View File

@ -6,4 +6,5 @@
@import "./charts.css";
@import "./tables.css";
@import "./report.css";
@import "./social-bi.css";
@import "./responsive.css";

View File

@ -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;

View 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
View 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
View 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 !== ""));
}

View 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
View 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
View 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");
});

View 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>
);
}

View 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>;
}

View 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();
});

View 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>
);
}

View 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>;
}

View 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();
});

View 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));
}

View 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();
});

View 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>
);
}

View 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>
);
}

View File

@ -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
View 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
View 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
View 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
View 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>
);

View 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;
}
}

View File

@ -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>

View File

@ -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();
}

View File

@ -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");
}
}

View File

@ -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} />;
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"
}
]
};
}

View File

@ -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>
);
}

View File

@ -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");
}
}

View File

@ -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"
};
}

View File

@ -1,16 +0,0 @@
export {
availableCountriesForModule,
availableProvidersForCountry,
createPaymentLinkRecord,
filterMoneyModules,
initialPaymentLinks,
moneyAppOptions,
moneyCountries,
moneyModuleMatchesRegion,
moneyModules,
moneyOperators,
moneyProviderOptions,
paymentStatusLabel,
summarizeMoneyModules,
trendLabels
} from "./model/data.js";

View File

@ -1 +0,0 @@
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";

View File

@ -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>
);

View File

@ -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;
}

View File

@ -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
});
}

View File

@ -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())}`;
}

View File

@ -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");
});

View File

@ -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(),
)}`;
}

View File

@ -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

View File

@ -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 || "-";
}

View File

@ -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
View File

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View File

@ -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}>

View 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");
});

View File

@ -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]);

View File

@ -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 = []) {

View File

@ -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) {

View File

@ -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",

View File

@ -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,
];

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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" });

View File

@ -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>(

View File

@ -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,

View 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,
};
}

View File

@ -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"

View 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);
}

View 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",
};
}

View File

@ -67,3 +67,11 @@ export function useCoinSellerAbilities() {
canView: can(PERMISSIONS.coinSellerView),
};
}
export function useHostWithdrawalAbilities() {
const { can } = useAuth();
return {
canView: can(PERMISSIONS.hostWithdrawalView),
};
}

View File

@ -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,
},
];

View File

@ -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>;

View File

@ -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 用户" },

View 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
View 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);
}

View 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>
);
}

View 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} />;
}

View 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);
}

View 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}
/>
</>
);
}

View 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)
};
}

View 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
}
];

View 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);
});
});

View 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>;

View File

@ -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");
});

View File

@ -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));
}

View File

@ -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));

View 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