Compare commits

..

No commits in common. "c836d0f702c9d0588ee9f795b8427eae6d42fb34" and "00b8020599a6b5dd894517bceacaaf42d441c9cf" have entirely different histories.

118 changed files with 5241 additions and 11904 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
<!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, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
@ -11,24 +11,16 @@ 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: getDefaultAppOptions(),
appOptions: [],
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);
@ -37,6 +29,7 @@ function DatabiOverviewApp() {
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");
@ -181,6 +174,7 @@ function DatabiOverviewApp() {
}, [appCode, countryId, gameId, range, regionId, timeZone]);
useEffect(() => {
//
if (!isDataReportSection) {
return undefined;
}
@ -188,6 +182,7 @@ function DatabiOverviewApp() {
}, [isDataReportSection, loadFilterOptions]);
useEffect(() => {
// 便
if (!isDataReportSection) {
return undefined;
}
@ -259,14 +254,6 @@ function DatabiOverviewApp() {
<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}
@ -286,7 +273,9 @@ function DatabiOverviewApp() {
)}
{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)} />
@ -336,17 +325,10 @@ function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools
function resolveSelectedAppCode(options, current) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => optionAppCode(item) === normalized)) {
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
return normalized;
}
if (options.length) {
return optionAppCode(options[0]);
}
return normalized || "lalu";
}
function optionAppCode(option) {
return String(option?.code ?? option?.value ?? "").trim().toLowerCase();
return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase();
}
function resolveRangePresetValue(range, timeZone) {
@ -411,7 +393,3 @@ 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,20 +1,13 @@
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, fetchSocialBiFilterOptions, fetchStatisticsOverview } from "./api.js";
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, 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())
}));
@ -26,29 +19,14 @@ beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
fetchFilterOptions.mockResolvedValue({
appOptions: [
{ code: "", label: "全部", value: "" },
{ code: "lalu", label: "Lalu", value: "lalu" },
{ code: "aslan", label: "Aslan", value: "aslan" },
{ code: "yumi", label: "Yumi", value: "yumi" }
],
appOptions: [{ code: "lalu", label: "Lalu", value: "lalu" }],
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();
});
@ -92,7 +70,7 @@ test("queries China natural day statistics when display timezone is Beijing", as
}));
});
test("renders legacy app metric columns in report view", async () => {
test("renders coin seller stock and outbound coin columns in report view", async () => {
fetchStatisticsOverview.mockResolvedValue({
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
@ -210,69 +188,6 @@ 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,37 +1,23 @@
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() {
const stored = window.localStorage.getItem(APP_CODE_KEY);
return stored === null ? DEFAULT_APP_CODE : normalizeAppCode(stored);
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
}
export function setCurrentAppCode(value) {
const appCode = normalizeAppCode(value);
const appCode = normalizeAppCode(value) || "lalu";
window.localStorage.setItem(APP_CODE_KEY, appCode);
return appCode;
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) {
const query = appScopedQuery(appCode);
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
if (statTz) {
query.stat_tz = statTz;
}
@ -54,25 +40,11 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
query.region_id = String(regionId);
}
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 })
});
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
}
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) {
const query = appScopedQuery(appCode);
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
if (statTz) {
query.stat_tz = statTz;
}
@ -92,42 +64,7 @@ export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endM
query.game_id = String(gameId);
}
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;
return fetchDatabiData("/v1/statistics/self-games/overview", { appCode, query });
}
export async function fetchFilterOptions({ appCode } = {}) {
@ -140,21 +77,6 @@ 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",
@ -223,12 +145,6 @@ 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 = [
@ -253,7 +169,7 @@ function normalizeFilterOptions({ apps, countries, regions }) {
.map((country) => normalizeCountryOption(country, countryRegionIds))
.filter(Boolean)
];
const apiAppOptions = normalizeList(apps)
const appOptions = normalizeList(apps)
.map((app) => {
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
return {
@ -263,7 +179,6 @@ function normalizeFilterOptions({ apps, countries, regions }) {
};
})
.filter((app) => app.code);
const appOptions = mergeAppOptions([ALL_APP_OPTION, ...apiAppOptions, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
return {
appOptions,
@ -272,65 +187,6 @@ 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;

View File

@ -1,115 +0,0 @@
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,34 +34,3 @@ 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,5 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
import { Fragment, useCallback, useMemo, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
@ -15,16 +14,6 @@ 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 },
@ -43,11 +32,9 @@ 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: "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: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false },
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
@ -101,7 +88,7 @@ const metricColumns = [
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
];
export function ReportOverview({ grantDrilldownQuery, loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
const [activeTab, setActiveTab] = useState("country");
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
@ -129,7 +116,7 @@ export function ReportOverview({ grantDrilldownQuery, loading = false, model, on
))}
</div>
<div className="report-tab-panel" role="tabpanel">
{activeTab === "country" ? <CountryReport grantDrilldownQuery={grantDrilldownQuery} loading={loading} rows={model.reportCountryRows} /> : null}
{activeTab === "country" ? <CountryReport 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}
@ -184,11 +171,8 @@ function ReportMetricCard({ card, loading, onOpenTrend }) {
);
}
function CountryReport({ grantDrilldownQuery, loading, rows }) {
function CountryReport({ 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);
@ -211,66 +195,17 @@ function CountryReport({ grantDrilldownQuery, 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 context={grantContext} dateLabel={row.date_label} rows={row.children || []} />}
renderExpandedRow={(row) => <CountryDetailTable 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>
);
}
@ -355,7 +290,7 @@ function ReportMetricTable({ loading, rows }) {
);
}
function ReportTable({ columns, context, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
function ReportTable({ columns, 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]);
@ -425,7 +360,7 @@ function ReportTable({ columns, context, defaultSort, loading, preserveOrder = f
].filter(Boolean).join(" ")}
key={column.key}
>
{column.render ? column.render(row, context) : row[column.key]}
{column.render ? column.render(row) : row[column.key]}
</td>
))}
</tr>
@ -443,7 +378,7 @@ function ReportTable({ columns, context, defaultSort, loading, preserveOrder = f
);
}
function CountryDetailTable({ context, dateLabel, rows }) {
function CountryDetailTable({ dateLabel, rows }) {
if (!rows.length) {
return <div className="report-subtable-empty">当前无数据</div>;
}
@ -463,7 +398,7 @@ function CountryDetailTable({ context, 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, context) : row[column.key]}</td>
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
))}
</tr>
))}
@ -473,269 +408,6 @@ function CountryDetailTable({ context, 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">
@ -772,18 +444,6 @@ 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>;
}
@ -915,110 +575,6 @@ 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,20 +1,9 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test, vi } from "vitest";
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
import { expect, test } from "vitest";
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({
@ -59,10 +48,6 @@ 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 国家明细" });
@ -82,94 +67,3 @@ 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,11 +22,9 @@ 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");
@ -40,191 +38,73 @@ 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",
},
{
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),
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) },
{
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",
},
{
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),
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) }
],
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,
@ -232,54 +112,22 @@ 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)
};
}
@ -309,10 +157,6 @@ 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) {
@ -360,7 +204,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
};
});
}
@ -375,32 +219,26 @@ 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);
}
@ -414,7 +252,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 || ""
}));
}
@ -422,40 +260,45 @@ 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;
}
@ -465,7 +308,12 @@ 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(":")
};
}
@ -491,7 +339,6 @@ function summarizeReportRows(rows) {
"coin_seller_transfer_coin",
"coin_total",
"consumed_coin",
"output_coin",
"game_payout",
"game_refund",
"game_turnover",
@ -512,30 +359,17 @@ 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] = sumOptionalField(rows, key);
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
return summary;
}, {});
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算;如果下游 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));
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算,不能把国家平均值相加
summary.avg_mic_online_ms = 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);
}
@ -546,94 +380,79 @@ 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 = 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 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 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 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);
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
return {
active_users: activeUsers,
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"),
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),
avg_mic_online_ms: avgMicOnlineMS,
coin_seller_recharge_usd_minor: readOptionalNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
coin_seller_recharge_usd_minor: readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
consumed_coin: consumedCoin,
consume_output_delta: consumeOutputDelta,
consume_output_ratio: consumeOutputRatio,
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
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") ?? (gameProfit === null || gameTurnover === null ? null : ratio(gameProfit, gameTurnover)),
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? ratio(gameProfit, gameTurnover),
game_refund: gameRefund,
game_turnover: gameTurnover,
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
google_recharge_usd_minor: readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
google_recharge_usd_minor: readNumber(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") ?? (luckyGiftProfit === null || luckyGiftTurnover === null ? null : ratio(luckyGiftProfit, luckyGiftTurnover)),
lucky_gift_profit_rate: readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? 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: 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"),
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),
paid_users: paidUsers,
payer_rate:
readOptionalNumber(item, "payer_rate", "payerRate", "paid_conversion_rate", "paidConversionRate") ?? (paidUsers === null || activeUsers === null ? null : ratio(paidUsers, activeUsers)),
output_coin: outputCoin,
payer_rate: ratio(paidUsers, activeUsers),
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
recharge_usd_minor: recharge,
recharge_conversion_rate: hasAnyKey(item, "recharge_conversion_rate", "rechargeConversionRate")
? readOptionalNumber(item, "recharge_conversion_rate", "rechargeConversionRate")
: rechargeUsers === null || activeUsers === null
? null
: ratio(rechargeUsers, activeUsers),
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
: 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: readOptionalNumber(item, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers"),
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
super_lucky_gift_payout: superLuckyGiftPayout,
super_lucky_gift_profit: superLuckyGiftProfit,
super_lucky_gift_profit_rate:
readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ??
(superLuckyGiftProfit === null || superLuckyGiftTurnover === null ? null : ratio(superLuckyGiftProfit, superLuckyGiftTurnover)),
super_lucky_gift_profit_rate: readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ?? 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);
@ -653,70 +472,56 @@ function normalizeDailySeries(source) {
return [];
}
return source.daily_series.map((item) => {
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 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 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 = 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 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 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 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);
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ??
(micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
return {
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"),
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"),
avg_mic_online_ms: avgMicOnlineMS,
coin_seller: coinSeller,
coin_seller_recharge_usd_minor: coinSellerMetric,
coin_seller_recharge_usd_minor: coinSeller,
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
consumed_coin: consumedCoin,
consume_output_delta: consumeOutputDelta,
consume_output_ratio: consumeOutputRatio,
game_payout: gamePayout,
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
game_profit: gameProfit,
game_turnover: gameTurnover,
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
gift_coin_spent: readNumber(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: luckyGiftProfit,
lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
lucky_gift_turnover: luckyGiftTurnover,
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
mic_online_ms: micOnlineMS,
mic_online_users: micOnlineUsers,
mifapay,
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"),
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"),
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
recharge_usd_minor: recharge,
recharge_users: readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
recharge_users: readNumber(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
};
});
}
@ -724,7 +529,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 }] : [];
}
@ -734,7 +539,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 [];
@ -749,7 +554,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) }
];
}
@ -767,21 +572,18 @@ 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) {
@ -796,7 +598,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,22 +2,19 @@ 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");
@ -26,61 +23,48 @@ 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("新增用户数");
@ -88,15 +72,12 @@ 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");
@ -107,205 +88,130 @@ 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,
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" } },
);
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" } });
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,
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]).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].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,
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,
}),
);
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
}));
});
function metricValue(model, label) {

View File

@ -1,505 +0,0 @@
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,5 +6,4 @@
@import "./charts.css";
@import "./tables.css";
@import "./report.css";
@import "./social-bi.css";
@import "./responsive.css";

View File

@ -611,39 +611,6 @@
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;
@ -744,241 +711,6 @@
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

@ -1,656 +0,0 @@
.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;
}
}

View File

@ -1,12 +0,0 @@
<!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>

View File

@ -1,444 +0,0 @@
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

@ -1,55 +0,0 @@
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());
});

View File

@ -1,217 +0,0 @@
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();
}

View File

@ -1,153 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,193 +0,0 @@
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

@ -1,67 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,156 +0,0 @@
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

@ -1,81 +0,0 @@
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

@ -1,207 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -1,77 +0,0 @@
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

@ -1,128 +0,0 @@
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

@ -1,72 +0,0 @@
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();
});

View File

@ -1,75 +0,0 @@
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);
}

View File

@ -1,286 +0,0 @@
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();
}

View File

@ -1,208 +0,0 @@
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("谷歌充值");
});

View File

@ -1,21 +0,0 @@
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

@ -1,438 +0,0 @@
: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>
<a href="/finance/">进入财务系统</a>
<div id="money-root"></div>
<script type="module" src="/money/src/main.jsx"></script>
</body>
</html>

319
money/src/MoneyApp.jsx Normal file
View File

@ -0,0 +1,319 @@
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();
}

99
money/src/api.js Normal file
View File

@ -0,0 +1,99 @@
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

@ -0,0 +1,43 @@
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

@ -0,0 +1,101 @@
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

@ -0,0 +1,90 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,85 @@
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

@ -0,0 +1,101 @@
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

@ -0,0 +1,101 @@
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

@ -0,0 +1,104 @@
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

@ -0,0 +1,560 @@
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

@ -0,0 +1,136 @@
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"
};
}

16
money/src/data.js Normal file
View File

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

1
money/src/format.js Normal file
View File

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

10
money/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
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>
);

306
money/src/masterDataApi.js Normal file
View File

@ -0,0 +1,306 @@
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

@ -0,0 +1,112 @@
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
});
}

424
money/src/model/data.js Normal file
View File

@ -0,0 +1,424 @@
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

@ -0,0 +1,56 @@
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");
});

32
money/src/model/format.js Normal file
View File

@ -0,0 +1,32 @@
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

@ -0,0 +1,55 @@
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
}))
);
}

1704
money/src/styles/index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
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

@ -0,0 +1,35 @@
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("已入账");
});

View File

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

View File

@ -1,107 +1,15 @@
import { Component } from "react";
import styles from "./ErrorBoundary.module.css";
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,
];
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 };
state = { error: null };
static getDerivedStateFromError(error) {
return { error, recovering: isRecoverableAssetLoadError(error) };
return { 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, recovering: false });
this.setState({ error: null });
};
reload = () => {
@ -113,17 +21,6 @@ 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

@ -1,21 +0,0 @@
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, fallbackNavigation);
return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
}, [backendMenus, can]);
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);

View File

@ -42,7 +42,6 @@ const deprecatedMenuCodes = new Set(["services"]);
const menuLabelOverrides = {
system: "后台设置",
"system-users": "后台用户",
"system-teams": "团队配置",
};
const iconMap = {
@ -134,7 +133,6 @@ 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 }),
],
},
{
@ -263,7 +261,6 @@ export const fallbackNavigation = [
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
routeNavItem("system-teams", { icon: GroupsOutlined }),
],
},
];
@ -343,10 +340,9 @@ export function filterNavigationByPermission(items = [], can) {
.filter(Boolean);
}
export function mergeNavigationItems(primaryItems = [], fallbackItems = [], orderItems = fallbackNavigation) {
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
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);
@ -356,15 +352,11 @@ export function mergeNavigationItems(primaryItems = [], fallbackItems = [], orde
}
if (fallbackItem.children?.length) {
existing.children = mergeNavigationItems(
existing.children || [],
fallbackItem.children,
orderItemByCode.get(fallbackItem.code)?.children || fallbackItem.children,
);
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
}
});
return orderNavigationItemsByFallback(merged, orderItems);
return orderNavigationItemsByFallback(merged, fallbackItems);
}
function orderNavigationItemsByFallback(items = [], fallbackItems = []) {

View File

@ -175,37 +175,6 @@ 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,9 +6,6 @@ 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",
@ -41,18 +38,8 @@ export const PERMISSIONS = {
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
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",
moneyView: "money:view",
moneyPaymentLinkCreate: "money:payment-link:create",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
@ -68,7 +55,6 @@ 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",
@ -201,7 +187,6 @@ export const MENU_CODES = {
overview: "overview",
system: "system",
systemUsers: "system-users",
systemTeams: "system-teams",
systemRoles: "system-roles",
systemMenus: "system-menus",
logs: "logs",
@ -273,7 +258,6 @@ 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,7 +29,6 @@ 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";
@ -72,7 +71,6 @@ 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, recyclePrettyDisplayID } from "./api";
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
afterEach(() => {
setAccessToken("");
@ -122,76 +122,6 @@ 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,7 +16,6 @@ import type {
PrettyDisplayIDGenerationBatchDto,
PrettyDisplayIDPoolDto,
PrettyDisplayIDPoolPayload,
RecyclePrettyDisplayIDPayload,
SetPrettyDisplayIDStatusPayload,
} from "@/shared/api/types";
@ -181,21 +180,6 @@ 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,
@ -630,7 +614,7 @@ function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) {
return undefined;
}
const user = normalizeAppUserBrief(item);
return nonZeroStringValue(user.userId) || nonZeroStringValue(user.displayUserId) || user.username ? user : undefined;
return user.userId || user.displayUserId || user.username ? user : undefined;
}
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
@ -708,7 +692,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: optionalEntityID(item.assignedUserId ?? item.assigned_user_id),
assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "",
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
createdByAdminId,
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
@ -764,12 +748,3 @@ 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,7 +1,6 @@
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,
@ -9,7 +8,6 @@ import {
grantPrettyDisplayID,
listPrettyDisplayIDPools,
listPrettyDisplayIDs,
recyclePrettyDisplayID,
setPrettyDisplayIDStatus,
updatePrettyDisplayIDPool,
} from "@/features/app-users/api";
@ -38,7 +36,6 @@ 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("");
@ -233,26 +230,6 @@ 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);
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
@ -356,7 +333,6 @@ export function usePrettyIdsPage() {
reloadAll,
resetIDFilters,
resetPoolFilters,
recyclePrettyID,
setActiveTab,
setGenerateForm,
setGrantForm,
@ -371,10 +347,3 @@ 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,5 +1,4 @@
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";
@ -366,43 +365,27 @@ function idColumns(page) {
key: "actions",
label: "操作",
width: "minmax(96px, 0.5fr)",
render: (item) => {
const showStatus = page.abilities.canPrettyUpdate && canChangePrettyStatus(item);
const showRecycle = page.abilities.canPrettyUpdate && canRecyclePrettyID(item);
if (!showStatus && !showRecycle) {
return "-";
}
return (
render: (item) =>
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
<div className={styles.rowActions}>
{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}
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
<SettingsOutlined fontSize="small" />
</ActionIcon>
</div>
);
},
) : (
"-"
),
},
];
}
function AssignedUserCell({ item }) {
const user = assignedUser(item);
if (!user) {
const assignedUserId = formatEntityID(item.assignedUserId);
if (!item.assignedUserId) {
return <span className={styles.meta}>-</span>;
}
return <AdminUserIdentity openInAppUserDetail user={user} />;
return <AdminUserIdentity openInAppUserDetail rows={[assignedUserId]} user={assignedUser(item)} />;
}
function OperatorCell({ item }) {
@ -433,16 +416,12 @@ function PrettyIDTimeCell({ item }) {
}
function assignedUser(item) {
const user = item.assignedUser || null;
if (!user) {
return null;
}
return {
...user,
displayUserId: user.displayUserId || user.defaultDisplayUserId,
...(item.assignedUser || {}),
displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId,
prettyDisplayUserId: item.displayUserId,
prettyId: item.prettyId,
userId: user.userId,
userId: item.assignedUser?.userId || item.assignedUserId,
};
}
@ -675,11 +654,11 @@ function StatusDialog({ page }) {
);
}
function ActionIcon({ children, label, ...props }) {
function ActionIcon({ children, label, onClick }) {
return (
<Tooltip arrow title={label}>
<span>
<IconButton label={label} {...props}>
<IconButton label={label} onClick={onClick}>
{children}
</IconButton>
</span>
@ -717,16 +696,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,13 +19,10 @@ 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", () => {
@ -110,7 +107,6 @@ function pageFixture(patch = {}) {
reloadAll: vi.fn(),
resetIDFilters: vi.fn(),
resetPoolFilters: vi.fn(),
recyclePrettyID: vi.fn(),
setActiveTab: vi.fn(),
setGenerateForm: vi.fn(),
setGrantForm: vi.fn(),
@ -145,7 +141,7 @@ function prettyIdFixture() {
return {
assignedAtMs: 1782499200000,
assignedUser: {
avatar: "https://cdn.example/owner-a.png",
avatar: "",
defaultDisplayUserId: "10001",
displayUserId: "777777",
userId: "9001",

View File

@ -1,7 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import {
adminAddAgencyHost,
createBDLeader,
createCountry,
createCoinSeller,
@ -102,11 +101,6 @@ 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];
@ -122,8 +116,7 @@ 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 [agencyAddHostUrl, agencyAddHostInit] = vi.mocked(fetch).mock.calls[13];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[14];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[13];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -184,12 +177,6 @@ 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,7 +3,6 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen
export { listRegions } from "@/shared/api/regions";
import type {
AgencyDto,
AgencyHostAddPayload,
AgencyJoinEnabledPayload,
AgencyStatusPayload,
ApiList,
@ -30,7 +29,6 @@ import type {
CreateManagerPayload,
EntityId,
HostCommandPayload,
HostWithdrawalDto,
HostProfileDto,
ManagerDto,
PageQuery,
@ -213,14 +211,6 @@ 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",
@ -371,17 +361,6 @@ 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,18 +3,11 @@ 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 {
adminAddAgencyHost,
createAgency,
deleteAgency,
listAgencies,
setAgencyJoinEnabled,
setAgencyStatus,
} from "@/features/host-org/api";
import { 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 { agencyHostAddSchema, createAgencySchema } from "@/features/host-org/schema";
import { createAgencySchema } from "@/features/host-org/schema";
const emptyAgencyForm = () => ({
commandId: makeCommandId("agency"),
@ -24,11 +17,6 @@ const emptyAgencyForm = () => ({
parentBdUserId: "",
reason: "",
});
const emptyAgencyHostForm = () => ({
commandId: makeCommandId("agency-host"),
targetUserId: "",
reason: "",
});
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
@ -50,8 +38,6 @@ 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,
@ -160,31 +146,6 @@ 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;
@ -269,9 +230,7 @@ export function useHostAgenciesPage() {
return {
abilities,
activeAction,
activeAgency,
agencyForm,
agencyHostForm,
changeOwnerFilter,
changeParentBdFilter,
changeQuery,
@ -291,21 +250,15 @@ export function useHostAgenciesPage() {
regionOptions,
reload,
resetFilters,
closeAction: () => {
setActiveAction("");
setActiveAgency(null);
},
closeAction: () => setActiveAction(""),
openAgencyForm: () => setActiveAction("agency"),
openAgencyHostForm,
removeAgencyFromList,
setAgencyForm,
setAgencyHostForm,
setPage,
sortBy,
sortDirection,
status,
submitAgency,
submitAgencyHost,
changeSort,
toggleAgencyStatusFromList,
toggleAgencyJoinEnabledFromList,

View File

@ -1,90 +0,0 @@
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,6 +1,5 @@
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";
@ -100,7 +99,6 @@ 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) => {
@ -247,35 +245,6 @@ 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>
);
}
@ -343,16 +312,6 @@ 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

@ -1,276 +0,0 @@
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

@ -1,175 +0,0 @@
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,11 +67,3 @@ export function useCoinSellerAbilities() {
canView: can(PERMISSIONS.coinSellerView),
};
}
export function useHostWithdrawalAbilities() {
const { can } = useAuth();
return {
canView: can(PERMISSIONS.hostWithdrawalView),
};
}

View File

@ -65,12 +65,4 @@ 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,10 +165,6 @@ export const createAgencySchema = commandBaseSchema.extend({
parentBdUserId: optionalUserIdSchema,
});
export const agencyHostAddSchema = commandBaseSchema.extend({
targetUserId: userIdSchema,
});
export const agencyCloseSchema = commandBaseSchema.extend({
agencyId: entityIdSchema,
});
@ -215,7 +211,6 @@ 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,7 +19,6 @@ 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

@ -1,45 +0,0 @@
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");
});

View File

@ -1,34 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,132 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,10 +0,0 @@
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 { getFinanceScopeCatalog, listUserFinanceScopes, listUsers, replaceUserFinanceScopes, updateUserStatus } from "./api";
import { getMoneyScopeCatalog, listUserMoneyScopes, listUsers, replaceUserMoneyScopes, 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", team_id: 2 });
const result = await listUsers({ page: 1, page_size: 10, status: "active" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(result.items).toEqual([]);
@ -21,7 +21,6 @@ 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");
});
@ -39,22 +38,22 @@ test("updateUserStatus sends generated endpoint path and payload", async () => {
expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" });
});
test("finance scope APIs use generated endpoint paths", async () => {
test("money 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 listUserFinanceScopes(9);
await replaceUserFinanceScopes(9, [{ appCode: "lalu", regionId: 2 }]);
await getFinanceScopeCatalog();
await listUserMoneyScopes(9);
await replaceUserMoneyScopes(9, [{ appCode: "lalu", regionId: 2 }]);
await getMoneyScopeCatalog();
const calls = vi.mocked(fetch).mock.calls;
expect(String(calls[0][0])).toContain("/api/v1/users/9/finance-scopes");
expect(String(calls[0][0])).toContain("/api/v1/users/9/money-scopes");
expect(calls[0][1]?.method).toBe("GET");
expect(String(calls[1][0])).toContain("/api/v1/users/9/finance-scopes");
expect(String(calls[1][0])).toContain("/api/v1/users/9/money-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/finance/scope");
expect(String(calls[2][0])).toContain("/api/v1/admin/money/scope");
expect(calls[2][1]?.method).toBe("GET");
});

View File

@ -8,7 +8,7 @@ import type {
PageQuery,
ResetPasswordResultDto,
UserFormPayload,
UserFinanceScopeDto,
UserMoneyScopeDto,
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 FinanceScopeCatalogDto {
export interface MoneyScopeCatalogDto {
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?: UserFinanceScopeDto[];
scopes?: UserMoneyScopeDto[];
}
export function listUserFinanceScopes(id: EntityId): Promise<{ items: UserFinanceScopeDto[]; total: number }> {
return apiRequest<{ items: UserFinanceScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserFinanceScopes, { id }));
export function listUserMoneyScopes(id: EntityId): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserMoneyScopes, { 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 }), {
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 }), {
body: { scopes },
method: "PUT"
});
}
export function getFinanceScopeCatalog(): Promise<FinanceScopeCatalogDto> {
return apiRequest<FinanceScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getFinanceScope));
export function getMoneyScopeCatalog(): Promise<MoneyScopeCatalogDto> {
return apiRequest<MoneyScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getMoneyScope));
}

View File

@ -1,104 +1,84 @@
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,
onFinanceScopeChecked,
onMoneyScopeChecked,
onRoleChecked,
onSubmit,
open,
roles,
setForm,
teams = []
setForm
}) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<form className="form-drawer form-drawer--sticky-actions" onSubmit={onSubmit}>
<form className="form-drawer" onSubmit={onSubmit}>
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2>
<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"
/>
<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 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 })}
/>
</section>
</div>
<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)}
/>
))
: 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>
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>
<Button type="submit" variant="primary">提交</Button>
@ -108,7 +88,7 @@ export function UserFormDrawer({
);
}
function FinanceScopeGroup({ app, checkedScopes, onChecked, regions }) {
function MoneyScopeGroup({ app, checkedScopes, onChecked, regions }) {
const appCode = app.appCode;
const isChecked = (regionId) =>
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));

View File

@ -1,48 +0,0 @@
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