财务系统和新BI
This commit is contained in:
parent
7858530fc8
commit
f059b0c60c
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HYApp 数据大屏</title>
|
<title>HYApp 数据中心</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="databi-root"></div>
|
<div id="databi-root"></div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
|
|||||||
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
||||||
import { MetricCard } from "./components/MetricCard.jsx";
|
import { MetricCard } from "./components/MetricCard.jsx";
|
||||||
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
||||||
|
import { ReportOverview } from "./components/ReportOverview.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
@ -26,6 +27,7 @@ export function DatabiApp() {
|
|||||||
const [countryId, setCountryId] = useState(0);
|
const [countryId, setCountryId] = useState(0);
|
||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
|
const [viewMode, setViewMode] = useState("report");
|
||||||
const [activeScreen, setActiveScreen] = useState("overview");
|
const [activeScreen, setActiveScreen] = useState("overview");
|
||||||
const [gameId, setGameId] = useState("all");
|
const [gameId, setGameId] = useState("all");
|
||||||
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
||||||
@ -192,13 +194,18 @@ export function DatabiApp() {
|
|||||||
|
|
||||||
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
||||||
const showSkeleton = loading && !overview;
|
const showSkeleton = loading && !overview;
|
||||||
|
const isReportView = viewMode === "report";
|
||||||
const openLuckyGiftPools = useCallback(() => {
|
const openLuckyGiftPools = useCallback(() => {
|
||||||
setPoolModalOpen(true);
|
setPoolModalOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="databi-shell">
|
<main className={`databi-shell databi-shell--${viewMode}`}>
|
||||||
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
|
<section className={[
|
||||||
|
"databi-screen",
|
||||||
|
isReportView ? "databi-screen--report" : "databi-screen--bigscreen",
|
||||||
|
activeScreen === "selfGames" ? "databi-screen--self-game" : ""
|
||||||
|
].filter(Boolean).join(" ")}>
|
||||||
<DatabiHeader
|
<DatabiHeader
|
||||||
activeScreen={activeScreen}
|
activeScreen={activeScreen}
|
||||||
appCode={appCode}
|
appCode={appCode}
|
||||||
@ -221,55 +228,75 @@ export function DatabiApp() {
|
|||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
timeZoneOptions={TIME_ZONE_OPTIONS}
|
timeZoneOptions={TIME_ZONE_OPTIONS}
|
||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeScreen === "selfGames" ? (
|
{activeScreen === "selfGames" ? (
|
||||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||||
|
) : isReportView ? (
|
||||||
|
<ReportOverview
|
||||||
|
loading={showSkeleton}
|
||||||
|
model={model}
|
||||||
|
onMetricTrend={setTrendModalItem}
|
||||||
|
onOpenLuckyGiftPools={openLuckyGiftPools}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<BigscreenOverview
|
||||||
<section className="metric-grid" aria-label="核心指标">
|
loading={showSkeleton}
|
||||||
{model.kpis.map((item) => (
|
model={model}
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
onMetricTrend={setTrendModalItem}
|
||||||
))}
|
onOpenLuckyGiftPools={openLuckyGiftPools}
|
||||||
</section>
|
/>
|
||||||
|
|
||||||
<section className="business-metric-grid" aria-label="业务指标">
|
|
||||||
{model.businessKpis.map((item) => (
|
|
||||||
<MetricCard
|
|
||||||
key={item.label}
|
|
||||||
item={item}
|
|
||||||
loading={showSkeleton}
|
|
||||||
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
|
|
||||||
{model.robotGiftKpis.map((item) => (
|
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="databi-grid databi-grid--top">
|
|
||||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
|
||||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
|
||||||
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeScreen === "overview" ? (
|
{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} />
|
||||||
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
|
||||||
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeScreen === "overview" && poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
||||||
|
{activeScreen === "overview" && trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="metric-grid" aria-label="核心指标">
|
||||||
|
{model.kpis.map((item) => (
|
||||||
|
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="business-metric-grid" aria-label="业务指标">
|
||||||
|
{model.businessKpis.map((item) => (
|
||||||
|
<MetricCard
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
loading={loading}
|
||||||
|
onClick={item.detailKey === "luckyGiftPools" ? onOpenLuckyGiftPools : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
|
||||||
|
{model.robotGiftKpis.map((item) => (
|
||||||
|
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="databi-grid databi-grid--top">
|
||||||
|
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={loading} topCountries={model.topCountries} />
|
||||||
|
<RevenueTrendPanel loading={loading} revenueSeries={model.revenueSeries} />
|
||||||
|
<FunnelPanel funnel={model.funnel} loading={loading} sideMetrics={model.sideMetrics} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSelectedAppCode(options, current) {
|
function resolveSelectedAppCode(options, current) {
|
||||||
const normalized = String(current || "").trim().toLowerCase();
|
const normalized = String(current || "").trim().toLowerCase();
|
||||||
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
|
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
|
||||||
@ -317,23 +344,26 @@ function countryBelongsToRegion(country, regionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enrichCountryBreakdown(data, countries) {
|
function enrichCountryBreakdown(data, countries) {
|
||||||
if (!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) {
|
if ((!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) &&
|
||||||
|
(!Array.isArray(data?.daily_country_breakdown) || !data.daily_country_breakdown.length)) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
|
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
|
||||||
|
const enrichRows = (rows) => rows.map((row) => {
|
||||||
|
const country = countryById.get(Number(row.country_id ?? row.countryId));
|
||||||
|
if (!country) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
country: row.country || country.name || country.label,
|
||||||
|
country_code: row.country_code || country.countryCode,
|
||||||
|
flag: row.flag || country.flag
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
country_breakdown: data.country_breakdown.map((row) => {
|
country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown,
|
||||||
const country = countryById.get(Number(row.country_id ?? row.countryId));
|
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
|
||||||
if (!country) {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
country: row.country || country.name || country.label,
|
|
||||||
country_code: row.country_code || country.countryCode,
|
|
||||||
flag: row.flag || country.flag
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,8 @@ test("keeps current data visible during scheduled refresh", async () => {
|
|||||||
|
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
|
||||||
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
expect(screen.getByLabelText("数据报表")).toBeTruthy();
|
||||||
|
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(60_000);
|
vi.advanceTimersByTime(60_000);
|
||||||
@ -50,7 +51,7 @@ test("keeps current data visible during scheduled refresh", async () => {
|
|||||||
|
|
||||||
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
|
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
|
||||||
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
|
||||||
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("queries China natural day statistics when display timezone is Beijing", async () => {
|
test("queries China natural day statistics when display timezone is Beijing", async () => {
|
||||||
@ -69,6 +70,35 @@ test("queries China natural day statistics when display timezone is Beijing", as
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders coin seller stock and outbound coin columns in report view", async () => {
|
||||||
|
fetchStatisticsOverview.mockResolvedValue({
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
salary_transfer_coin: 88_000,
|
||||||
|
country_breakdown: [
|
||||||
|
{
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
country: "巴西",
|
||||||
|
country_id: 86,
|
||||||
|
salary_transfer_coin: 88_000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
updated_at_ms: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DatabiApp />);
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "币商充值金币" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "币商出货金币" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "工资兑换金币" })).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("400,000").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("160,000").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("88,000").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("loads self game statistics from the big screen switch", async () => {
|
test("loads self game statistics from the big screen switch", async () => {
|
||||||
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||||
fetchSelfGameStatisticsOverview.mockResolvedValue({
|
fetchSelfGameStatisticsOverview.mockResolvedValue({
|
||||||
@ -103,6 +133,9 @@ test("renders real-room robot gift cards in a separate row", async () => {
|
|||||||
render(<DatabiApp />);
|
render(<DatabiApp />);
|
||||||
|
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole("tab", { name: "大屏 BI" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
const businessSection = screen.getByLabelText("业务指标");
|
const businessSection = screen.getByLabelText("业务指标");
|
||||||
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");
|
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");
|
||||||
|
|||||||
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
|
||||||
|
import { niceAxis } from "./createMetricTrendOption.js";
|
||||||
|
|
||||||
|
export function createReportRevenueOption(series) {
|
||||||
|
const labels = series.map((item) => item.label);
|
||||||
|
const channelValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller));
|
||||||
|
const totalValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total));
|
||||||
|
const channelAxis = niceAxis(channelValues);
|
||||||
|
const totalAxis = niceAxis(totalValues);
|
||||||
|
|
||||||
|
return {
|
||||||
|
animationDuration: 520,
|
||||||
|
color: ["#1688d9", "#18a389", "#6377e8", "#172033"],
|
||||||
|
grid: { bottom: 30, left: 54, right: 54, top: 42 },
|
||||||
|
legend: [
|
||||||
|
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 18, textStyle: { color: "#5f6f86", fontSize: 12 }, top: 0 },
|
||||||
|
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#172033", fontSize: 12 }, top: 0 }
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderColor: "#d8e2ed",
|
||||||
|
extraCssText: "box-shadow: 0 14px 34px rgba(15, 23, 42, .14);",
|
||||||
|
textStyle: { color: "#172033" },
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLabel: { color: "#6b7a90", margin: 10 },
|
||||||
|
axisLine: { lineStyle: { color: "#dbe5ef" } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
boundaryGap: true,
|
||||||
|
data: labels,
|
||||||
|
type: "category"
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||||
|
axisTick: { show: false },
|
||||||
|
interval: channelAxis.interval,
|
||||||
|
max: channelAxis.max,
|
||||||
|
min: 0,
|
||||||
|
splitLine: { lineStyle: { color: "rgba(104, 123, 148, 0.16)", type: "dashed" } },
|
||||||
|
type: "value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||||
|
axisTick: { show: false },
|
||||||
|
interval: totalAxis.interval,
|
||||||
|
max: totalAxis.max,
|
||||||
|
min: 0,
|
||||||
|
splitLine: { show: false },
|
||||||
|
type: "value"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{ barGap: "-100%", barWidth: 26, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
|
||||||
|
{ barWidth: 26, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
|
||||||
|
{ barWidth: 26, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
|
||||||
|
{
|
||||||
|
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
|
||||||
|
lineStyle: { color: "#172033", shadowBlur: 8, shadowColor: "rgba(23, 32, 51, .14)", width: 3 },
|
||||||
|
name: "总计 (USD)",
|
||||||
|
smooth: 0.42,
|
||||||
|
symbol: "circle",
|
||||||
|
symbolSize: 7,
|
||||||
|
itemStyle: { borderColor: "#ffffff", borderWidth: 2, color: "#172033" },
|
||||||
|
type: "line",
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
|
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
||||||
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
|
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
|
||||||
|
|
||||||
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
|
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
|
||||||
@ -33,11 +34,13 @@ export function DatabiHeader({
|
|||||||
onRangeChange,
|
onRangeChange,
|
||||||
onRegionChange,
|
onRegionChange,
|
||||||
onTimeZoneChange,
|
onTimeZoneChange,
|
||||||
|
onViewModeChange,
|
||||||
range,
|
range,
|
||||||
regionId,
|
regionId,
|
||||||
regionOptions = defaultRegionOptions,
|
regionOptions = defaultRegionOptions,
|
||||||
timeZone,
|
timeZone,
|
||||||
timeZoneOptions = TIME_ZONE_OPTIONS
|
timeZoneOptions = TIME_ZONE_OPTIONS,
|
||||||
|
viewMode = "report"
|
||||||
}) {
|
}) {
|
||||||
const [openControl, setOpenControl] = useState("");
|
const [openControl, setOpenControl] = useState("");
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
@ -77,10 +80,22 @@ export function DatabiHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="databi-header" ref={headerRef}>
|
<header className="databi-header" ref={headerRef}>
|
||||||
<div className="brand-lockup">
|
<div className="databi-header-left">
|
||||||
<div className="brand-emblem" aria-hidden="true" />
|
<div className="brand-lockup">
|
||||||
<div>
|
<div className="brand-emblem" aria-hidden="true">
|
||||||
<h1>App Data</h1>
|
<StorageOutlined fontSize="small" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>数据中心</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="view-mode-switch" role="tablist" aria-label="视图切换">
|
||||||
|
<button aria-selected={viewMode === "report"} className={viewMode === "report" ? "is-active" : ""} onClick={() => onViewModeChange?.("report")} role="tab" type="button">
|
||||||
|
报表视图
|
||||||
|
</button>
|
||||||
|
<button aria-selected={viewMode === "bigscreen"} className={viewMode === "bigscreen" ? "is-active" : ""} onClick={() => onViewModeChange?.("bigscreen")} role="tab" type="button">
|
||||||
|
大屏 BI
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="screen-switch" aria-label="数据大屏切换">
|
<nav className="screen-switch" aria-label="数据大屏切换">
|
||||||
|
|||||||
537
databi/src/components/ReportOverview.jsx
Normal file
537
databi/src/components/ReportOverview.jsx
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
import { 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";
|
||||||
|
|
||||||
|
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
|
const reportTabs = [
|
||||||
|
{ key: "country", label: "国家经营报表" },
|
||||||
|
{ key: "revenue", label: "收入趋势" },
|
||||||
|
{ key: "gift", label: "礼物消费" },
|
||||||
|
{ key: "lucky", label: "幸运礼物" },
|
||||||
|
{ key: "game", label: "游戏流水" },
|
||||||
|
{ key: "robot", label: "机器人送礼" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const countryColumns = [
|
||||||
|
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false },
|
||||||
|
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
|
||||||
|
{ key: "registrations", label: "注册", render: (row) => formatOptionalCoin(row.registrations), sortable: false },
|
||||||
|
{ key: "active_users", label: "活跃用户", render: (row) => formatOptionalCoin(row.active_users), sortable: false },
|
||||||
|
{ key: "paid_users", label: "付费用户", render: (row) => formatOptionalCoin(row.paid_users), sortable: false },
|
||||||
|
{ key: "recharge_users", label: "充值用户", render: (row) => formatOptionalCoin(row.recharge_users), sortable: false },
|
||||||
|
{ key: "recharge_usd_minor", label: "总充值", render: (row) => formatOptionalMoney(row.recharge_usd_minor), sortable: false },
|
||||||
|
{ key: "new_user_recharge_usd_minor", label: "新用户充值", render: (row) => formatOptionalMoney(row.new_user_recharge_usd_minor), sortable: false },
|
||||||
|
{ key: "coin_seller_recharge_usd_minor", label: "币商充值", render: (row) => formatOptionalMoney(row.coin_seller_recharge_usd_minor), sortable: false },
|
||||||
|
{ key: "coin_seller_stock_coin", label: "币商充值金币", render: (row) => formatOptionalCoin(row.coin_seller_stock_coin), sortable: false },
|
||||||
|
{ key: "coin_seller_transfer_coin", label: "币商出货金币", render: (row) => formatOptionalCoin(row.coin_seller_transfer_coin), sortable: false },
|
||||||
|
{ key: "google_recharge_usd_minor", label: "谷歌充值", render: (row) => formatOptionalMoney(row.google_recharge_usd_minor), sortable: false },
|
||||||
|
{ key: "mifapay_recharge_usd_minor", label: "三方充值", render: (row) => formatOptionalMoney(row.mifapay_recharge_usd_minor), sortable: false },
|
||||||
|
{ key: "game_turnover", label: "游戏流水/利润率", render: (row) => <FlowRateCell rate={row.game_profit_rate} value={row.game_turnover} />, sortable: false },
|
||||||
|
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
|
||||||
|
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
|
||||||
|
{ key: "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: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
|
||||||
|
{ key: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false },
|
||||||
|
{ key: "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 },
|
||||||
|
{ key: "avg_mic_online_ms", label: "平均麦上时间", render: (row) => formatDuration(row.avg_mic_online_ms), sortable: false },
|
||||||
|
{ key: "mic_online_ms", label: "麦上总时长", render: (row) => formatDuration(row.mic_online_ms), sortable: false },
|
||||||
|
{ key: "arpu_usd_minor", label: "ARPU", render: (row) => formatOptionalMoney(row.arpu_usd_minor), sortable: false },
|
||||||
|
{ key: "arppu_usd_minor", label: "ARPPU", render: (row) => formatOptionalMoney(row.arppu_usd_minor), sortable: false },
|
||||||
|
{ key: "payer_rate", label: "付费转化率", render: (row) => formatOptionalPercent(row.payer_rate), sortable: false },
|
||||||
|
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const revenueColumns = [
|
||||||
|
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||||||
|
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
|
||||||
|
{ key: "mifapay", label: "MifaPay", render: (row) => formatMoneyFull(row.mifapay) },
|
||||||
|
{ key: "coin_seller", label: "币商", render: (row) => formatMoneyFull(row.coin_seller) },
|
||||||
|
{ key: "total", label: "渠道合计", render: (row) => formatMoneyFull(row.total) },
|
||||||
|
{ key: "lineTotal", label: "总计", render: (row) => formatMoneyFull(row.lineTotal ?? row.total) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const giftColumns = [
|
||||||
|
{ align: "left", key: "name", label: "礼物", render: (row) => row.name, sortValue: (row) => row.name, type: "text" },
|
||||||
|
{ key: "value", label: "金币消费", render: (row) => formatCoin(row.value) },
|
||||||
|
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) },
|
||||||
|
{ key: "trend_rate", label: "近 7 日", render: (row) => <TrendValue value={row.trend_rate} /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
const luckyColumns = [
|
||||||
|
{ align: "left", key: "pool_id", label: "奖池", render: (row) => row.pool_id, sortValue: (row) => row.pool_id, type: "text" },
|
||||||
|
{ key: "turnover", label: "流水", render: (row) => formatCoin(row.turnover) },
|
||||||
|
{ key: "payout", label: "返奖", render: (row) => formatCoin(row.payout) },
|
||||||
|
{ key: "profit", label: "利润", render: (row) => formatCoin(row.profit) },
|
||||||
|
{ key: "payout_rate", label: "返奖率", render: (row) => formatPercent(row.payout_rate) },
|
||||||
|
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const gameColumns = [
|
||||||
|
{ align: "left", key: "game_id", label: "游戏", render: (row) => row.game_label || row.game_id, sortValue: (row) => row.game_label || row.game_id, type: "text" },
|
||||||
|
{ key: "turnover_coin", label: "流水 (金币)", render: (row) => formatCoin(row.turnover_coin) },
|
||||||
|
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) },
|
||||||
|
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const metricColumns = [
|
||||||
|
{ align: "left", key: "label", label: "指标", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||||||
|
{ key: "value", label: "数值", render: (row) => row.value, sortable: false },
|
||||||
|
{ key: "unit", label: "单位", render: (row) => row.unit || "--", sortable: false },
|
||||||
|
{ key: "caption", label: "对比口径", render: (row) => row.caption || "--", sortable: false },
|
||||||
|
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||||||
|
const [activeTab, setActiveTab] = useState("country");
|
||||||
|
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="databi-report" aria-label="数据报表">
|
||||||
|
<section className="report-metric-grid" aria-label="核心经营指标">
|
||||||
|
{summaryCards.map((card) => (
|
||||||
|
<ReportMetricCard card={card} key={card.title} loading={loading} onOpenTrend={onMetricTrend} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="report-workbench">
|
||||||
|
<div className="report-tabs" role="tablist" aria-label="报表切换">
|
||||||
|
{reportTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
className={activeTab === tab.key ? "is-active" : ""}
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="report-tab-panel" role="tabpanel">
|
||||||
|
{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}
|
||||||
|
{activeTab === "game" ? <GameReport loading={loading} metrics={model.businessKpis} rows={model.gameRanking} /> : null}
|
||||||
|
{activeTab === "robot" ? <RobotReport loading={loading} rows={model.robotGiftKpis} /> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportMetricCard({ card, loading, onOpenTrend }) {
|
||||||
|
const clickable = Boolean(card.trendMetric?.trend?.length && onOpenTrend);
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<article className="report-metric-card is-loading" aria-busy="true">
|
||||||
|
<span className="skeleton-block report-skeleton-title" />
|
||||||
|
<span className="skeleton-block report-skeleton-value" />
|
||||||
|
<span className="skeleton-block report-skeleton-line" />
|
||||||
|
<span className="skeleton-block report-skeleton-line report-skeleton-line--short" />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={["report-metric-card", clickable ? "report-metric-card--clickable" : ""].filter(Boolean).join(" ")}
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
onClick={clickable ? () => onOpenTrend(card.trendMetric) : undefined}
|
||||||
|
onKeyDown={clickable ? (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenTrend(card.trendMetric);
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
<div className="report-metric-head">
|
||||||
|
<span>{card.title}</span>
|
||||||
|
{card.delta ? <DeltaText value={card.delta} /> : null}
|
||||||
|
</div>
|
||||||
|
<strong>{card.value}</strong>
|
||||||
|
<div className="report-metric-detail-grid">
|
||||||
|
{card.details.filter((detail) => detail.label !== card.title).map((detail) => (
|
||||||
|
<span className="report-metric-detail" key={detail.label}>
|
||||||
|
<small>{detail.label}</small>
|
||||||
|
<b>{detail.value}</b>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountryReport({ loading, rows }) {
|
||||||
|
return (
|
||||||
|
<ReportPanel>
|
||||||
|
<ReportTable columns={countryColumns} loading={loading} preserveOrder rows={rows} wide />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevenueReport({ loading, rows }) {
|
||||||
|
return (
|
||||||
|
<ReportPanel>
|
||||||
|
{loading ? (
|
||||||
|
<div className="report-chart-skeleton" aria-busy="true">
|
||||||
|
<span className="skeleton-block report-skeleton-line" />
|
||||||
|
<span className="skeleton-block report-skeleton-chart" />
|
||||||
|
</div>
|
||||||
|
) : rows.length ? (
|
||||||
|
<EChart className="report-revenue-chart" option={createReportRevenueOption(rows)} />
|
||||||
|
) : (
|
||||||
|
<div className="report-empty">当前无数据</div>
|
||||||
|
)}
|
||||||
|
<ReportTable columns={revenueColumns} defaultSort={{ direction: "asc", key: "label", type: "text" }} loading={loading} rows={rows} />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiftReport({ loading, metrics, rows }) {
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.value || 0), 0) || 1;
|
||||||
|
const giftRows = rows.map((row) => ({ ...row, share: Number(row.value || 0) / total }));
|
||||||
|
return (
|
||||||
|
<ReportPanel>
|
||||||
|
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("礼物") || item.label.includes("币商"))} />
|
||||||
|
<ReportTable columns={giftColumns} defaultSort={{ direction: "desc", key: "value", type: "number" }} loading={loading} rows={giftRows} />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LuckyReport({ loading, metrics, onOpenLuckyGiftPools, rows }) {
|
||||||
|
return (
|
||||||
|
<ReportPanel
|
||||||
|
action={(
|
||||||
|
<button className="report-panel-action" disabled={!rows.length} type="button" onClick={onOpenLuckyGiftPools}>
|
||||||
|
查看奖池明细
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("幸运礼物"))} />
|
||||||
|
<ReportTable columns={luckyColumns} defaultSort={{ direction: "desc", key: "turnover", type: "number" }} loading={loading} rows={rows} />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameReport({ loading, metrics, rows }) {
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.turnover_coin || 0), 0) || 1;
|
||||||
|
const gameRows = rows.map((row) => ({ ...row, share: Number(row.turnover_coin || 0) / total }));
|
||||||
|
return (
|
||||||
|
<ReportPanel>
|
||||||
|
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("游戏"))} />
|
||||||
|
<ReportTable columns={gameColumns} defaultSort={{ direction: "desc", key: "turnover_coin", type: "number" }} loading={loading} rows={gameRows} />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RobotReport({ loading, rows }) {
|
||||||
|
return (
|
||||||
|
<ReportPanel>
|
||||||
|
<ReportMetricTable loading={loading} rows={rows} />
|
||||||
|
</ReportPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportPanel({ action, children }) {
|
||||||
|
return (
|
||||||
|
<section className="report-panel">
|
||||||
|
{action ? <div className="report-panel-head">{action}</div> : null}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportMetricTable({ loading, rows }) {
|
||||||
|
return (
|
||||||
|
<div className="report-metric-table-block">
|
||||||
|
<ReportTable columns={metricColumns} loading={loading} rows={rows} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, 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]);
|
||||||
|
|
||||||
|
const handleSort = (column) => {
|
||||||
|
if (preserveOrder || column.sortable === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSortState((current) => ({
|
||||||
|
direction: current?.key === column.key && current.direction === "desc" ? "asc" : "desc",
|
||||||
|
key: column.key,
|
||||||
|
type: column.type || "number"
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-table-wrap">
|
||||||
|
<table className={["report-table", wide ? "report-table--wide" : ""].filter(Boolean).join(" ")}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isSortable = !preserveOrder && column.sortable !== false;
|
||||||
|
const isActive = sortState?.key === column.key;
|
||||||
|
return (
|
||||||
|
<th className={isSortable ? "is-sortable" : ""} key={column.key}>
|
||||||
|
{isSortable ? (
|
||||||
|
<button
|
||||||
|
className={["report-sort-button", column.align === "left" ? "report-sort-button--left" : "", isActive ? "is-active" : ""].filter(Boolean).join(" ")}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSort(column)}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
<i>{isActive ? (sortState.direction === "asc" ? "↑" : "↓") : "↕"}</i>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
column.label
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
|
||||||
|
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
|
||||||
|
{!loading && sortedRows.map((row, index) => (
|
||||||
|
<tr className={reportRowClass(row)} key={reportRowKey(row, index)}>
|
||||||
|
<td>{reportRowIndex(row, index)}</td>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountryCell({ row }) {
|
||||||
|
return (
|
||||||
|
<span className="report-country-cell">
|
||||||
|
{row.flag ? <i>{row.flag}</i> : null}
|
||||||
|
<span>{row.country}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateCell({ row }) {
|
||||||
|
return <span className="report-date-cell">{row.date_label || "--"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowRateCell({ rate, value }) {
|
||||||
|
if (isMissing(value) && isMissing(rate)) {
|
||||||
|
return <span className="report-placeholder">--</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="report-pair-cell">
|
||||||
|
<b>{formatOptionalCoin(value)}</b>
|
||||||
|
<small>{formatOptionalPercent(rate)}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendValue({ value }) {
|
||||||
|
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaText({ value }) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return <span className="report-delta report-delta--empty">--</span>;
|
||||||
|
}
|
||||||
|
return <span className={text.startsWith("-") ? "report-delta report-delta--down" : "report-delta"}>{text.startsWith("-") ? "↓" : "↑"} {text.replace(/^[-+]/, "")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSkeletonRows({ colSpan }) {
|
||||||
|
return Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<tr className="report-table-skeleton-row" key={index}>
|
||||||
|
<td colSpan={colSpan}><span className="skeleton-block report-skeleton-line" /></td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportEmptyRow({ colSpan }) {
|
||||||
|
return (
|
||||||
|
<tr className="report-table-empty-row">
|
||||||
|
<td colSpan={colSpan}>当前无数据</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummaryCards(model) {
|
||||||
|
const totalRecharge = model.kpis[0] || {};
|
||||||
|
const rechargeSplit = model.kpis[1]?.subMetrics || [];
|
||||||
|
const newUsers = model.kpis[2] || {};
|
||||||
|
const activeUsers = model.kpis[3] || {};
|
||||||
|
const paidSplit = model.kpis[4]?.subMetrics || [];
|
||||||
|
const arpu = model.kpis[5] || {};
|
||||||
|
const arppu = model.kpis[6] || {};
|
||||||
|
const payerRate = model.sideMetrics?.find((item) => item.label === "付费转化率") || {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
delta: totalRecharge.delta,
|
||||||
|
details: [
|
||||||
|
{ label: "用户充值", value: rechargeSplit[0]?.value || "--" },
|
||||||
|
{ label: "新用户充值", value: rechargeSplit[1]?.value || "--" }
|
||||||
|
],
|
||||||
|
title: "总充值",
|
||||||
|
trendMetric: totalRecharge,
|
||||||
|
value: totalRecharge.value || "--",
|
||||||
|
valueLabel: "总充值"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delta: activeUsers.delta,
|
||||||
|
details: [
|
||||||
|
{ label: "新增用户", value: newUsers.value || "--" },
|
||||||
|
{ label: "活跃用户", value: activeUsers.value || "--" }
|
||||||
|
],
|
||||||
|
title: "活跃用户",
|
||||||
|
trendMetric: activeUsers,
|
||||||
|
value: activeUsers.value || "--",
|
||||||
|
valueLabel: "活跃用户"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delta: paidSplit[0]?.delta,
|
||||||
|
details: [
|
||||||
|
{ label: "付费用户", value: paidSplit[0]?.value || "--" },
|
||||||
|
{ label: "新增付费", value: paidSplit[1]?.value || "--" }
|
||||||
|
],
|
||||||
|
title: "付费转化率",
|
||||||
|
trendMetric: model.kpis[4],
|
||||||
|
value: payerRate.value || "--",
|
||||||
|
valueLabel: "付费转化率"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delta: arpu.delta,
|
||||||
|
details: [
|
||||||
|
{ label: "ARPU", value: arpu.value || "--" },
|
||||||
|
{ label: "ARPPU", value: arppu.value || "--" }
|
||||||
|
],
|
||||||
|
title: "ARPPU",
|
||||||
|
trendMetric: arpu,
|
||||||
|
value: arppu.value || "--",
|
||||||
|
valueLabel: "ARPPU"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstSortableColumn(columns) {
|
||||||
|
const column = columns.find((item) => item.sortable !== false);
|
||||||
|
return column ? { direction: column.type === "text" ? "asc" : "desc", key: column.key, type: column.type || "number" } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(rows, sortState, columns) {
|
||||||
|
if (!sortState?.key) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const column = columns.find((item) => item.key === sortState.key);
|
||||||
|
if (!column) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const multiplier = sortState.direction === "asc" ? 1 : -1;
|
||||||
|
return rows
|
||||||
|
.map((row, index) => ({ index, row }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const result = compareValues(readSortValue(left.row, column), readSortValue(right.row, column), sortState.type);
|
||||||
|
return result === 0 ? left.index - right.index : result * multiplier;
|
||||||
|
})
|
||||||
|
.map(({ row }) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSortValue(row, column) {
|
||||||
|
return column.sortValue ? column.sortValue(row) : row[column.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareValues(leftValue, rightValue, type) {
|
||||||
|
if (type === "text") {
|
||||||
|
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
|
||||||
|
}
|
||||||
|
const leftNumber = Number(leftValue);
|
||||||
|
const rightNumber = Number(rightValue);
|
||||||
|
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(leftNumber)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(rightNumber)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return leftNumber - rightNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedPercent(value) {
|
||||||
|
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportRowKey(row, index) {
|
||||||
|
return [row.row_id, row.country, row.name, row.label, row.pool_id, row.game_id, row.platform_code, row.value, index]
|
||||||
|
.filter((item) => item !== undefined && item !== null && item !== "")
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportRowClass(row) {
|
||||||
|
if (row.row_type === "total") {
|
||||||
|
return "report-row-total";
|
||||||
|
}
|
||||||
|
if (row.row_type === "date_total") {
|
||||||
|
return "report-row-date-total";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportRowIndex(row, index) {
|
||||||
|
if (row.row_type === "total") {
|
||||||
|
return "总";
|
||||||
|
}
|
||||||
|
if (row.row_type === "date_total") {
|
||||||
|
return "日";
|
||||||
|
}
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalMoney(value) {
|
||||||
|
return isMissing(value) ? <span className="report-placeholder">--</span> : formatMoneyFull(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalCoin(value) {
|
||||||
|
return isMissing(value) ? <span className="report-placeholder">--</span> : formatCoin(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionalPercent(value) {
|
||||||
|
return isMissing(value) ? <span className="report-placeholder">--</span> : formatPercent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value) {
|
||||||
|
if (isMissing(value)) {
|
||||||
|
return <span className="report-placeholder">--</span>;
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissing(value) {
|
||||||
|
return value === undefined || value === null || Number.isNaN(Number(value));
|
||||||
|
}
|
||||||
@ -33,16 +33,18 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
||||||
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
const gameProfit = readNumber(source, "game_profit", "gameProfit");
|
||||||
const countryBreakdown = normalizeCountries(source, countryId);
|
const countryBreakdown = normalizeCountries(source, countryId);
|
||||||
|
const dailyCountryBreakdown = normalizeDailyCountryBreakdown(source);
|
||||||
const dailySeries = normalizeDailySeries(source);
|
const dailySeries = normalizeDailySeries(source);
|
||||||
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
||||||
const payoutDistribution = normalizeDistribution(source);
|
const payoutDistribution = normalizeDistribution(source);
|
||||||
const gameRanking = normalizeGameRanking(source);
|
const gameRanking = normalizeGameRanking(source);
|
||||||
|
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appCode,
|
appCode,
|
||||||
businessKpis: [
|
businessKpis: [
|
||||||
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
|
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { 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("币商出货金币", 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("幸运礼物流水", 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("幸运礼物返奖", 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("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
|
||||||
@ -50,6 +52,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
|
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
|
||||||
],
|
],
|
||||||
countryBreakdown,
|
countryBreakdown,
|
||||||
|
dailyCountryBreakdown,
|
||||||
funnel: [
|
funnel: [
|
||||||
{ name: "活跃用户", rate: 1, value: activeUsers },
|
{ name: "活跃用户", rate: 1, value: activeUsers },
|
||||||
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
||||||
@ -105,6 +108,8 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
],
|
],
|
||||||
luckyGiftPools,
|
luckyGiftPools,
|
||||||
payoutDistribution,
|
payoutDistribution,
|
||||||
|
reportCountryRows,
|
||||||
|
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
||||||
revenueSeries,
|
revenueSeries,
|
||||||
robotGiftKpis: [
|
robotGiftKpis: [
|
||||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
|
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
|
||||||
@ -143,6 +148,15 @@ function readNumber(source, ...keys) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalNumber(source, ...keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (source?.[key] !== undefined && source[key] !== null) {
|
||||||
|
return numberValue(source[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readDelta(source, ...keys) {
|
function readDelta(source, ...keys) {
|
||||||
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@ -229,6 +243,131 @@ function normalizeCountries(source, countryId) {
|
|||||||
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDailyCountryBreakdown(source) {
|
||||||
|
const rows = firstArray(source?.daily_country_breakdown, source?.dailyCountryBreakdown);
|
||||||
|
if (!rows.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||||||
|
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 || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source }) {
|
||||||
|
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
|
||||||
|
country: "总计",
|
||||||
|
date_label: "全部",
|
||||||
|
row_type: "total"
|
||||||
|
});
|
||||||
|
if (!isMultiDayRange(range)) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!dayKeys.length) {
|
||||||
|
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) || {}) };
|
||||||
|
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||||
|
country: "总计",
|
||||||
|
date_label: day,
|
||||||
|
row_type: "date_total",
|
||||||
|
stat_day: day
|
||||||
|
}));
|
||||||
|
if (dayCountries.length) {
|
||||||
|
rows.push(...dayCountries.map((row) => createReportRow(row, {
|
||||||
|
date_label: day,
|
||||||
|
row_type: "country",
|
||||||
|
stat_day: day
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dailyCountryBreakdown.length) {
|
||||||
|
rows.push(...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" })));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReportRow(row, extra) {
|
||||||
|
return {
|
||||||
|
...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(":")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRowsByDay(rows) {
|
||||||
|
return rows.reduce((groups, row) => {
|
||||||
|
const day = row.stat_day || row.date_label || "";
|
||||||
|
if (!day) {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
if (!groups.has(day)) {
|
||||||
|
groups.set(day, []);
|
||||||
|
}
|
||||||
|
groups.get(day).push(row);
|
||||||
|
return groups;
|
||||||
|
}, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeReportRows(rows) {
|
||||||
|
const fields = [
|
||||||
|
"active_users",
|
||||||
|
"coin_seller_recharge_usd_minor",
|
||||||
|
"coin_seller_stock_coin",
|
||||||
|
"coin_seller_transfer_coin",
|
||||||
|
"game_payout",
|
||||||
|
"game_refund",
|
||||||
|
"game_turnover",
|
||||||
|
"gift_coin_spent",
|
||||||
|
"google_recharge_usd_minor",
|
||||||
|
"lucky_gift_payout",
|
||||||
|
"lucky_gift_turnover",
|
||||||
|
"mifapay_recharge_usd_minor",
|
||||||
|
"new_user_recharge_usd_minor",
|
||||||
|
"paid_users",
|
||||||
|
"recharge_usd_minor",
|
||||||
|
"recharge_users",
|
||||||
|
"registrations",
|
||||||
|
"salary_transfer_coin",
|
||||||
|
"super_lucky_gift_payout",
|
||||||
|
"super_lucky_gift_turnover"
|
||||||
|
];
|
||||||
|
return fields.reduce((summary, key) => {
|
||||||
|
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
|
||||||
|
return summary;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMultiDayRange(range) {
|
||||||
|
return Boolean(range?.start && range?.end && range.start !== range.end);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCountryRow(item, index, totalRecharge) {
|
function normalizeCountryRow(item, index, totalRecharge) {
|
||||||
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
|
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
|
||||||
const countryId = numberValue(item.country_id ?? item.countryId);
|
const countryId = numberValue(item.country_id ?? item.countryId);
|
||||||
@ -238,26 +377,64 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
const activeUsers = numberValue(item.active_users);
|
const activeUsers = numberValue(item.active_users);
|
||||||
const paidUsers = numberValue(item.paid_users);
|
const paidUsers = numberValue(item.paid_users);
|
||||||
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
|
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") ??
|
||||||
|
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
|
||||||
return {
|
return {
|
||||||
active_users: activeUsers,
|
active_users: activeUsers,
|
||||||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
||||||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
||||||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
||||||
|
avg_mic_online_ms: readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs"),
|
||||||
|
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: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
|
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
|
||||||
|
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||||||
|
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
|
||||||
country: meta?.label || country,
|
country: meta?.label || country,
|
||||||
|
country_id: countryId,
|
||||||
country_code: code || meta?.code || "",
|
country_code: code || meta?.code || "",
|
||||||
flag: countryFlag(code || meta?.code),
|
flag: countryFlag(code || meta?.code),
|
||||||
|
game_payout: gamePayout,
|
||||||
|
game_profit: gameProfit,
|
||||||
|
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,
|
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||||
|
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||||
|
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||||||
|
lucky_gift_payout: luckyGiftPayout,
|
||||||
|
lucky_gift_profit: luckyGiftProfit,
|
||||||
|
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: readOptionalNumber(item, "mic_online_ms", "micOnlineMs"),
|
||||||
|
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),
|
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
||||||
paid_users: paidUsers,
|
paid_users: paidUsers,
|
||||||
payer_rate: ratio(paidUsers, activeUsers),
|
payer_rate: ratio(paidUsers, activeUsers),
|
||||||
|
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||||||
recharge_usd_minor: recharge,
|
recharge_usd_minor: recharge,
|
||||||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
||||||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
||||||
: ratio(rechargeUsers, activeUsers),
|
: ratio(rechargeUsers, activeUsers),
|
||||||
recharge_users: rechargeUsers,
|
recharge_users: rechargeUsers,
|
||||||
|
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||||
|
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||||
share: recharge / totalRecharge,
|
share: recharge / totalRecharge,
|
||||||
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
|
registrations: 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") ?? ratio(superLuckyGiftProfit, superLuckyGiftTurnover),
|
||||||
|
super_lucky_gift_turnover: superLuckyGiftTurnover,
|
||||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -300,6 +477,7 @@ function normalizeDailySeries(source) {
|
|||||||
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||||
coin_seller: coinSeller,
|
coin_seller: coinSeller,
|
||||||
coin_seller_recharge_usd_minor: coinSeller,
|
coin_seller_recharge_usd_minor: coinSeller,
|
||||||
|
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||||||
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||||||
game_profit: gameProfit,
|
game_profit: gameProfit,
|
||||||
game_turnover: gameTurnover,
|
game_turnover: gameTurnover,
|
||||||
@ -317,6 +495,7 @@ function normalizeDailySeries(source) {
|
|||||||
paid_users: readNumber(item, "paid_users", "paidUsers"),
|
paid_users: readNumber(item, "paid_users", "paidUsers"),
|
||||||
recharge_usd_minor: recharge,
|
recharge_usd_minor: recharge,
|
||||||
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
||||||
|
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||||
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
|
||||||
total: recharge,
|
total: recharge,
|
||||||
user_recharge_usd_minor: userRecharge
|
user_recharge_usd_minor: userRecharge
|
||||||
|
|||||||
@ -25,14 +25,18 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => {
|
|||||||
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
|
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
|
||||||
const model = createDashboardModel({
|
const model = createDashboardModel({
|
||||||
coin_seller_recharge_usd_minor: 200,
|
coin_seller_recharge_usd_minor: 200,
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
coin_seller_transfer_coin: 160_000,
|
coin_seller_transfer_coin: 160_000,
|
||||||
|
salary_transfer_coin: 88_000,
|
||||||
country_breakdown: [
|
country_breakdown: [
|
||||||
{
|
{
|
||||||
coin_seller_recharge_usd_minor: 200,
|
coin_seller_recharge_usd_minor: 200,
|
||||||
|
coin_seller_stock_coin: 400_000,
|
||||||
coin_seller_transfer_coin: 160_000,
|
coin_seller_transfer_coin: 160_000,
|
||||||
country: "阿富汗",
|
country: "阿富汗",
|
||||||
google_recharge_usd_minor: 150,
|
google_recharge_usd_minor: 150,
|
||||||
recharge_usd_minor: 150
|
recharge_usd_minor: 150,
|
||||||
|
salary_transfer_coin: 88_000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
daily_series: [
|
daily_series: [
|
||||||
@ -48,9 +52,9 @@ test("includes coin seller stock recharge in USD total and keeps seller transfer
|
|||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||||
|
|
||||||
expect(metricValue(model, "总充值")).toBe("$4");
|
expect(metricValue(model, "总充值")).toBe("$4");
|
||||||
expect(metricValue(model, "币商转账金币")).toBe("160,000");
|
expect(metricValue(model, "币商出货金币")).toBe("160,000");
|
||||||
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
|
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
|
||||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 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 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exposes new user KPI and removes visitor stage from funnel", () => {
|
test("exposes new user KPI and removes visitor stage from funnel", () => {
|
||||||
@ -124,6 +128,55 @@ test("uses registration count instead of legacy visitors in country breakdown",
|
|||||||
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("builds report rows with range total, daily totals, and daily country rows", () => {
|
||||||
|
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,
|
||||||
|
country: "巴西",
|
||||||
|
country_id: 86,
|
||||||
|
game_turnover: 300,
|
||||||
|
lucky_gift_turnover: 200,
|
||||||
|
recharge_usd_minor: 100,
|
||||||
|
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, label: "2026-06-05", recharge_usd_minor: 100, 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", "country", "date_total", "country"]);
|
||||||
|
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
||||||
|
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({ country: "总计", date_label: "2026-06-05" }));
|
||||||
|
expect(model.reportCountryRows[2]).toEqual(expect.objectContaining({
|
||||||
|
coin_seller_stock_coin: 800,
|
||||||
|
coin_seller_transfer_coin: 500,
|
||||||
|
country: "巴西",
|
||||||
|
game_turnover: 300,
|
||||||
|
lucky_gift_turnover: 200,
|
||||||
|
salary_transfer_coin: 700,
|
||||||
|
super_lucky_gift_profit: 60
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
function metricValue(model, label) {
|
function metricValue(model, label) {
|
||||||
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,5 @@
|
|||||||
@import "./panels.css";
|
@import "./panels.css";
|
||||||
@import "./charts.css";
|
@import "./charts.css";
|
||||||
@import "./tables.css";
|
@import "./tables.css";
|
||||||
|
@import "./report.css";
|
||||||
@import "./responsive.css";
|
@import "./responsive.css";
|
||||||
|
|||||||
@ -50,24 +50,11 @@
|
|||||||
box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
|
box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-emblem::before {
|
.brand-emblem svg {
|
||||||
width: 19px;
|
width: 23px;
|
||||||
height: 19px;
|
height: 23px;
|
||||||
border: 2px solid #27e4f5;
|
color: #27e4f5;
|
||||||
border-radius: 50%;
|
filter: drop-shadow(0 0 8px rgba(39, 228, 245, 0.38));
|
||||||
background:
|
|
||||||
linear-gradient(90deg, transparent 45%, #27e4f5 45% 55%, transparent 55%),
|
|
||||||
linear-gradient(transparent 45%, #27e4f5 45% 55%, transparent 55%);
|
|
||||||
box-shadow: 0 0 12px rgba(39, 228, 245, 0.55);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-emblem::after {
|
|
||||||
position: absolute;
|
|
||||||
inset: 7px;
|
|
||||||
border: 1px solid rgba(39, 228, 245, 0.55);
|
|
||||||
border-radius: 50%;
|
|
||||||
content: "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup h1 {
|
.brand-lockup h1 {
|
||||||
|
|||||||
713
databi/src/styles/report.css
Normal file
713
databi/src/styles/report.css
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
.databi-header-left {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid rgba(61, 141, 190, 0.42);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(7, 28, 47, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9eb7ce;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-switch button:hover,
|
||||||
|
.view-mode-switch button.is-active {
|
||||||
|
background: rgba(37, 216, 245, 0.18);
|
||||||
|
color: #e9f6ff;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-screen--report {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report {
|
||||||
|
min-width: 1180px;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .databi-header {
|
||||||
|
height: auto;
|
||||||
|
min-height: 72px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: center;
|
||||||
|
row-gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e4ebf3;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .header-controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .brand-emblem {
|
||||||
|
border-color: #b9dcf4;
|
||||||
|
background: #eef8ff;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(22, 136, 217, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .brand-emblem svg {
|
||||||
|
color: #1688d9;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .brand-lockup h1 {
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .view-mode-switch,
|
||||||
|
.databi-shell--report .screen-switch,
|
||||||
|
.databi-shell--report .game-switch {
|
||||||
|
border-color: #d8e5f0;
|
||||||
|
background: #eef4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .view-mode-switch button,
|
||||||
|
.databi-shell--report .screen-switch button,
|
||||||
|
.databi-shell--report .game-switch button {
|
||||||
|
color: #5f6f86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .view-mode-switch button:hover,
|
||||||
|
.databi-shell--report .view-mode-switch button.is-active,
|
||||||
|
.databi-shell--report .screen-switch button:hover,
|
||||||
|
.databi-shell--report .screen-switch button.is-active,
|
||||||
|
.databi-shell--report .game-switch button:hover,
|
||||||
|
.databi-shell--report .game-switch button.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #126fb2;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-trigger,
|
||||||
|
.databi-shell--report .date-range-trigger {
|
||||||
|
border-color: #d8e5f0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-trigger:hover,
|
||||||
|
.databi-shell--report .filter-trigger[aria-expanded="true"],
|
||||||
|
.databi-shell--report .date-range-trigger:hover,
|
||||||
|
.databi-shell--report .date-range-trigger[aria-expanded="true"] {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-label,
|
||||||
|
.databi-shell--report .date-range-label {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #718198;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-trigger:hover .filter-label,
|
||||||
|
.databi-shell--report .filter-trigger[aria-expanded="true"] .filter-label,
|
||||||
|
.databi-shell--report .date-range-trigger:hover .date-range-label,
|
||||||
|
.databi-shell--report .date-range-trigger[aria-expanded="true"] .date-range-label,
|
||||||
|
.databi-shell--report .control-glyph,
|
||||||
|
.databi-shell--report .filter-label .filter-icon {
|
||||||
|
color: #1688d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-value,
|
||||||
|
.databi-shell--report .date-range-values b {
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-icon,
|
||||||
|
.databi-shell--report .filter-chevron,
|
||||||
|
.databi-shell--report .date-range-chevron,
|
||||||
|
.databi-shell--report .date-range-label svg {
|
||||||
|
color: #718198;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .date-range-values i {
|
||||||
|
color: #8c9aab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-popover,
|
||||||
|
.databi-shell--report .date-time-popover {
|
||||||
|
border-color: #d8e5f0;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-popover button,
|
||||||
|
.databi-shell--report .date-time-shortcuts button,
|
||||||
|
.databi-shell--report .date-time-fields button,
|
||||||
|
.databi-shell--report .calendar-toolbar button,
|
||||||
|
.databi-shell--report .calendar-day,
|
||||||
|
.databi-shell--report .time-column button,
|
||||||
|
.databi-shell--report .date-time-footer button {
|
||||||
|
color: #34445a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-popover button:hover,
|
||||||
|
.databi-shell--report .filter-popover button.is-active,
|
||||||
|
.databi-shell--report .date-time-shortcuts button:hover,
|
||||||
|
.databi-shell--report .date-time-shortcuts button.is-active,
|
||||||
|
.databi-shell--report .calendar-day:hover,
|
||||||
|
.databi-shell--report .time-column button:hover,
|
||||||
|
.databi-shell--report .time-column button.is-active {
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-empty,
|
||||||
|
.databi-shell--report .filter-search span,
|
||||||
|
.databi-shell--report .date-time-fields span,
|
||||||
|
.databi-shell--report .date-time-footer span,
|
||||||
|
.databi-shell--report .time-column > span,
|
||||||
|
.databi-shell--report .calendar-weekday {
|
||||||
|
color: #718198;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-search input,
|
||||||
|
.databi-shell--report .date-time-fields button {
|
||||||
|
border-color: #d8e5f0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .filter-search input:focus,
|
||||||
|
.databi-shell--report .date-time-fields button.is-active {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .calendar-toolbar,
|
||||||
|
.databi-shell--report .calendar-pane,
|
||||||
|
.databi-shell--report .date-time-fields,
|
||||||
|
.databi-shell--report .date-time-footer,
|
||||||
|
.databi-shell--report .time-column,
|
||||||
|
.databi-shell--report .time-column > span {
|
||||||
|
border-color: #e4ebf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .calendar-toolbar strong,
|
||||||
|
.databi-shell--report .date-time-fields b {
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .calendar-day.is-muted {
|
||||||
|
color: #b5c0cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .calendar-day.is-start,
|
||||||
|
.databi-shell--report .calendar-day.is-end,
|
||||||
|
.databi-shell--report .calendar-day.is-active {
|
||||||
|
background: #1688d9;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .calendar-day.is-between {
|
||||||
|
background: #e9f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .date-time-footer button {
|
||||||
|
border-color: #1688d9;
|
||||||
|
background: #1688d9;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-report {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-card {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 142px;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid #dfe8f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-card--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-card--clickable:hover,
|
||||||
|
.report-metric-card--clickable:focus-visible {
|
||||||
|
border-color: #1688d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.08), 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-head {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-head > span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #53647b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-card > strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 780;
|
||||||
|
line-height: 1.14;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-primary-label {
|
||||||
|
color: #7b8a9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-detail {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-detail small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8a98aa;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-detail b {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #243044;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 760;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-workbench {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dfe8f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-tabs {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
border-bottom: 1px solid #e4ebf3;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-tabs button {
|
||||||
|
height: 38px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #65748a;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-tabs button:hover,
|
||||||
|
.report-tabs button.is-active {
|
||||||
|
border-bottom-color: #1688d9;
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-tab-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel-action {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #c7d7e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #126fb2;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel-action:hover {
|
||||||
|
border-color: #1688d9;
|
||||||
|
background: #eef7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-panel-action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-metric-table-block {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 860px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table--wide {
|
||||||
|
min-width: 2860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table th,
|
||||||
|
.report-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-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #65748a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table th:first-child,
|
||||||
|
.report-table td:first-child {
|
||||||
|
width: 48px;
|
||||||
|
color: #8a98aa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table td.is-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:hover td {
|
||||||
|
background: #f7fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-row-total td {
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #172033;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-row-date-total td {
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #243044;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-row-total:hover td,
|
||||||
|
.report-row-date-total:hover td {
|
||||||
|
background: #e9f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sort-button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sort-button--left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sort-button:hover,
|
||||||
|
.report-sort-button.is-active {
|
||||||
|
color: #126fb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sort-button i {
|
||||||
|
color: #9aa7b8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-country-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 220px;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-country-cell i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-country-cell span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-date-cell {
|
||||||
|
color: #53647b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pair-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 118px;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pair-cell b {
|
||||||
|
color: #243044;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pair-cell small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-trend-up,
|
||||||
|
.report-delta {
|
||||||
|
color: #0f9b72;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-trend-down,
|
||||||
|
.report-delta--down {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delta--empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-skeleton-row td,
|
||||||
|
.report-table-empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-empty-row td {
|
||||||
|
height: 96px;
|
||||||
|
color: #8a98aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 240px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed #d8e5f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #8a98aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-revenue-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chart-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e4ebf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-skeleton-title {
|
||||||
|
width: 112px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-skeleton-value {
|
||||||
|
width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-skeleton-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-skeleton-line--short {
|
||||||
|
width: 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-skeleton-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .databi-panel,
|
||||||
|
.databi-shell--report .mini-stat,
|
||||||
|
.databi-shell--report .metric-card {
|
||||||
|
border-color: #dfe8f2;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .panel-title,
|
||||||
|
.databi-shell--report .self-game-title strong,
|
||||||
|
.databi-shell--report .metric-content strong,
|
||||||
|
.databi-shell--report .risk-user-copy strong,
|
||||||
|
.databi-shell--report .definition-row strong {
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .panel-info {
|
||||||
|
border-color: #c7d7e7;
|
||||||
|
color: #6b7a90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .metric-label-row,
|
||||||
|
.databi-shell--report .mini-stat > span,
|
||||||
|
.databi-shell--report .section-label,
|
||||||
|
.databi-shell--report .rank-title,
|
||||||
|
.databi-shell--report .self-game-title span,
|
||||||
|
.databi-shell--report .definition-row span,
|
||||||
|
.databi-shell--report .risk-user-copy small {
|
||||||
|
color: #65748a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .metric-title-icon,
|
||||||
|
.databi-shell--report .metric-unit-icon {
|
||||||
|
color: #1688d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .metric-delta {
|
||||||
|
border-top-color: #e4ebf3;
|
||||||
|
color: #7b8a9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .self-game-table th,
|
||||||
|
.databi-shell--report .self-game-table td {
|
||||||
|
border-bottom-color: #edf2f7;
|
||||||
|
color: #34445a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .self-game-table th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #65748a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .definition-row {
|
||||||
|
border-color: #e4ebf3;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.databi-shell--report .panel-empty {
|
||||||
|
color: #8a98aa;
|
||||||
|
}
|
||||||
12
money/index.html
Normal file
12
money/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HYApp 财务系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="money-root"></div>
|
||||||
|
<script type="module" src="/money/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
234
money/src/MoneyApp.jsx
Normal file
234
money/src/MoneyApp.jsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
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 {
|
||||||
|
createPaymentLinkRecord,
|
||||||
|
filterMoneyModules,
|
||||||
|
initialPaymentLinks,
|
||||||
|
moneyAppOptions,
|
||||||
|
moneyCountries,
|
||||||
|
moneyOperators,
|
||||||
|
moneyScopeOptions,
|
||||||
|
summarizeMoneyModules
|
||||||
|
} from "./data.js";
|
||||||
|
|
||||||
|
export function MoneyApp() {
|
||||||
|
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||||
|
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 [scope, setScope] = useState("");
|
||||||
|
const [operatorKeyword, setOperatorKeyword] = useState("");
|
||||||
|
const [links, setLinks] = useState(() => initialPaymentLinks);
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredModules = useMemo(
|
||||||
|
() =>
|
||||||
|
filterMoneyModules({
|
||||||
|
activeOperatorId,
|
||||||
|
appCode,
|
||||||
|
countryCode,
|
||||||
|
mode,
|
||||||
|
operatorKeyword: mode === "finance" ? operatorKeyword : "",
|
||||||
|
scope
|
||||||
|
}),
|
||||||
|
[activeOperatorId, appCode, countryCode, mode, operatorKeyword, scope]
|
||||||
|
);
|
||||||
|
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 activeOperator = moneyOperators.find((operator) => operator.id === activeOperatorId) || moneyOperators[0];
|
||||||
|
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setAppCode("");
|
||||||
|
setCountryCode("");
|
||||||
|
setScope("");
|
||||||
|
setOperatorKeyword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (message) => {
|
||||||
|
setToast(message);
|
||||||
|
window.setTimeout(() => setToast(""), 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLink = (payload) => {
|
||||||
|
const nextLink = createPaymentLinkRecord({
|
||||||
|
...payload,
|
||||||
|
operatorName: mode === "operator" ? activeOperator.label : undefined
|
||||||
|
});
|
||||||
|
setLinks((current) => [nextLink, ...current]);
|
||||||
|
showToast("支付链接已创建");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async (value) => {
|
||||||
|
await copyText(value);
|
||||||
|
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={moneyAppOptions}
|
||||||
|
countryCode={countryCode}
|
||||||
|
countryOptions={[["", "全部国家"], ...moneyCountries.map((country) => [country.code, country.label])]}
|
||||||
|
mode={mode}
|
||||||
|
onActiveOperatorChange={setActiveOperatorId}
|
||||||
|
onAppChange={setAppCode}
|
||||||
|
onCountryChange={setCountryCode}
|
||||||
|
onExport={exportReport}
|
||||||
|
onOperatorKeywordChange={setOperatorKeyword}
|
||||||
|
onReset={resetFilters}
|
||||||
|
onScopeChange={setScope}
|
||||||
|
operatorKeyword={operatorKeyword}
|
||||||
|
operators={moneyOperators}
|
||||||
|
scope={scope}
|
||||||
|
scopeOptions={moneyScopeOptions}
|
||||||
|
view={view}
|
||||||
|
/>
|
||||||
|
<div className="money-content">{renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks })}</div>
|
||||||
|
</section>
|
||||||
|
{toast ? <div className="money-toast">{toast}</div> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks }) {
|
||||||
|
if (activeView === "modules") {
|
||||||
|
return <PerformanceTable meta={`${filteredModules.length} modules`} modules={filteredModules} summary={summary} title="模块绩效" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView === "operators") {
|
||||||
|
return <OperatorPerformancePanel modules={filteredModules} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView === "payments") {
|
||||||
|
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreateLink={createLink} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView === "countries") {
|
||||||
|
return <CountryChannelPanel links={visibleLinks} modules={filteredModules} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
}
|
||||||
51
money/src/api.js
Normal file
51
money/src/api.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
|
||||||
|
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||||
|
const MONEY_PERMISSION = "money:view";
|
||||||
|
|
||||||
|
export async function fetchMoneySession() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/auth/me`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: requestHeaders()
|
||||||
|
});
|
||||||
|
const payload = await readJSON(response);
|
||||||
|
if (response.status === 401 || Number(payload?.code) === 40100) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new Error("登录已失效");
|
||||||
|
}
|
||||||
|
if (!response.ok || payload.code !== 0) {
|
||||||
|
throw new Error(payload.message || response.statusText || "会话校验失败");
|
||||||
|
}
|
||||||
|
const session = payload.data || {};
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
canViewMoney: Array.isArray(session.permissions) && session.permissions.includes(MONEY_PERMISSION)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHeaders() {
|
||||||
|
const headers = {};
|
||||||
|
const token = window.localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 redirectToLogin() {
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
43
money/src/charts/EChart.jsx
Normal file
43
money/src/charts/EChart.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
67
money/src/components/CountryChannelPanel.jsx
Normal file
67
money/src/components/CountryChannelPanel.jsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { availableProvidersForCountry, moneyCountries, summarizeMoneyModules } from "../data.js";
|
||||||
|
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||||
|
|
||||||
|
export function CountryChannelPanel({ links, modules }) {
|
||||||
|
const rows = moneyCountries
|
||||||
|
.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 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;
|
||||||
|
}
|
||||||
75
money/src/components/MoneyHeader.jsx
Normal file
75
money/src/components/MoneyHeader.jsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
|
export function MoneyHeader({
|
||||||
|
activeOperatorId,
|
||||||
|
appCode,
|
||||||
|
appOptions,
|
||||||
|
countryCode,
|
||||||
|
countryOptions,
|
||||||
|
mode,
|
||||||
|
onActiveOperatorChange,
|
||||||
|
onAppChange,
|
||||||
|
onCountryChange,
|
||||||
|
onExport,
|
||||||
|
onOperatorKeywordChange,
|
||||||
|
onReset,
|
||||||
|
onScopeChange,
|
||||||
|
operatorKeyword,
|
||||||
|
operators,
|
||||||
|
scope,
|
||||||
|
scopeOptions,
|
||||||
|
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 label="App" options={appOptions} value={appCode} onChange={onAppChange} />
|
||||||
|
<FilterSelect label="国家" options={countryOptions} value={countryCode} onChange={onCountryChange} />
|
||||||
|
<FilterSelect label="模块" options={scopeOptions} value={scope} onChange={onScopeChange} />
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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({ label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<label className="money-filter">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
{options.map(([optionValue, optionLabel]) => (
|
||||||
|
<option key={optionValue || "all"} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
money/src/components/MoneyKpis.jsx
Normal file
23
money/src/components/MoneyKpis.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
money/src/components/MoneyOverview.jsx
Normal file
85
money/src/components/MoneyOverview.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
money/src/components/MoneySidebar.jsx
Normal file
101
money/src/components/MoneySidebar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
money/src/components/MoneyTrendPanel.jsx
Normal file
101
money/src/components/MoneyTrendPanel.jsx
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
92
money/src/components/OperatorPerformancePanel.jsx
Normal file
92
money/src/components/OperatorPerformancePanel.jsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { moneyOperators, summarizeMoneyModules } from "../data.js";
|
||||||
|
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||||
|
|
||||||
|
export function OperatorPerformancePanel({ modules }) {
|
||||||
|
const rows = moneyOperators
|
||||||
|
.map((operator) => {
|
||||||
|
const ownedModules = modules.filter((module) => module.operatorId === operator.id);
|
||||||
|
return {
|
||||||
|
...operator,
|
||||||
|
modules: ownedModules,
|
||||||
|
summary: summarizeMoneyModules(ownedModules)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.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} modules</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 Stack({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<span className="money-stack">
|
||||||
|
<strong>{primary || "-"}</strong>
|
||||||
|
<small>{secondary || "-"}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
money/src/components/PaymentLinkPanel.jsx
Normal file
107
money/src/components/PaymentLinkPanel.jsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
||||||
|
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||||
|
import { availableCountriesForModule, availableProvidersForCountry, paymentStatusLabel } from "../data.js";
|
||||||
|
import { formatDateTime, formatUsd } from "../format.js";
|
||||||
|
import { PanelHead } from "./MoneyTrendPanel.jsx";
|
||||||
|
|
||||||
|
export function PaymentLinkPanel({ links, modules, onCopy, onCreateLink }) {
|
||||||
|
const [moduleId, setModuleId] = useState(() => modules[0]?.id || "");
|
||||||
|
const countries = useMemo(() => availableCountriesForModule(moduleId), [moduleId]);
|
||||||
|
const [countryCode, setCountryCode] = useState(() => countries[0]?.code || "");
|
||||||
|
const providers = useMemo(() => availableProvidersForCountry(countryCode), [countryCode]);
|
||||||
|
const [providerCode, setProviderCode] = useState(() => providers[0]?.code || "");
|
||||||
|
const [amountUsd, setAmountUsd] = useState("99");
|
||||||
|
const canSubmit = Boolean(moduleId && countryCode && providerCode && Number(amountUsd) > 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modules.some((module) => module.id === moduleId)) {
|
||||||
|
setModuleId(modules[0]?.id || "");
|
||||||
|
}
|
||||||
|
}, [moduleId, modules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!countries.some((country) => country.code === countryCode)) {
|
||||||
|
setCountryCode(countries[0]?.code || "");
|
||||||
|
}
|
||||||
|
}, [countries, countryCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providers.some((provider) => provider.code === providerCode)) {
|
||||||
|
setProviderCode(providers[0]?.code || "");
|
||||||
|
}
|
||||||
|
}, [providerCode, providers]);
|
||||||
|
|
||||||
|
const submit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreateLink({ amountUsd, countryCode, moduleId, providerCode });
|
||||||
|
setAmountUsd("99");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="money-panel money-link-panel">
|
||||||
|
<PanelHead label="国家支付链接" meta={`${modules.length} modules`} value={String(links.length)} />
|
||||||
|
<form className="money-link-form" onSubmit={submit}>
|
||||||
|
<Select label="负责模块" value={moduleId} onChange={setModuleId} options={modules.map((module) => [module.id, module.moduleName])} />
|
||||||
|
<Select label="国家" value={countryCode} onChange={setCountryCode} options={countries.map((country) => [country.code, `${country.label} / ${country.currency}`])} />
|
||||||
|
<Select label="渠道" value={providerCode} onChange={setProviderCode} options={providers.map((provider) => [provider.code, provider.label])} />
|
||||||
|
<label className="money-field">
|
||||||
|
<span>金额 USD</span>
|
||||||
|
<input min="1" step="0.01" type="number" value={amountUsd} onChange={(event) => setAmountUsd(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<button className="money-button money-button--primary" disabled={!canSubmit} type="submit">
|
||||||
|
<AddLinkOutlined fontSize="small" />
|
||||||
|
创建链接
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="money-link-list">
|
||||||
|
{links.slice(0, 5).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" ? "money-status--success" : ""}`}>{paymentStatusLabel(link.status)}</span>
|
||||||
|
<small>{formatDateTime(link.createdAtMs)}</small>
|
||||||
|
</aside>
|
||||||
|
<nav>
|
||||||
|
<button className="money-icon-button" onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
|
||||||
|
<ContentCopyOutlined fontSize="small" />
|
||||||
|
</button>
|
||||||
|
<button className="money-icon-button" onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
|
||||||
|
<OpenInNewOutlined fontSize="small" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<label className="money-field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
{options.map(([optionValue, optionLabel]) => (
|
||||||
|
<option key={optionValue} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(value) {
|
||||||
|
if (value) {
|
||||||
|
window.open(value, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
}
|
||||||
136
money/src/components/PerformanceTable.jsx
Normal file
136
money/src/components/PerformanceTable.jsx
Normal 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} modules`}</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" ? "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
16
money/src/data.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
availableCountriesForModule,
|
||||||
|
availableProvidersForCountry,
|
||||||
|
createPaymentLinkRecord,
|
||||||
|
filterMoneyModules,
|
||||||
|
initialPaymentLinks,
|
||||||
|
moneyAppOptions,
|
||||||
|
moneyCountries,
|
||||||
|
moneyModules,
|
||||||
|
moneyOperators,
|
||||||
|
moneyProviderOptions,
|
||||||
|
moneyScopeOptions,
|
||||||
|
paymentStatusLabel,
|
||||||
|
summarizeMoneyModules,
|
||||||
|
trendLabels
|
||||||
|
} from "./model/data.js";
|
||||||
1
money/src/format.js
Normal file
1
money/src/format.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";
|
||||||
10
money/src/main.jsx
Normal file
10
money/src/main.jsx
Normal 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>
|
||||||
|
);
|
||||||
402
money/src/model/data.js
Normal file
402
money/src/model/data.js
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
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 moneyScopeOptions = [
|
||||||
|
["", "全部模块"],
|
||||||
|
["app", "App 模块"],
|
||||||
|
["region", "App 地区模块"],
|
||||||
|
];
|
||||||
|
|
||||||
|
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 = "",
|
||||||
|
scope = "",
|
||||||
|
} = {}) {
|
||||||
|
const normalizedKeyword = operatorKeyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
return modules.filter((module) => {
|
||||||
|
const matchesRole = mode !== "operator" || module.operatorId === activeOperatorId;
|
||||||
|
const matchesApp = !appCode || module.appCode === appCode;
|
||||||
|
const matchesScope = !scope || module.scope === scope;
|
||||||
|
const matchesCountry =
|
||||||
|
!countryCode || module.countryCode === countryCode || (module.scope === "app" && module.countryCode === "ALL");
|
||||||
|
const matchesOperator =
|
||||||
|
!normalizedKeyword ||
|
||||||
|
module.operatorName.toLowerCase().includes(normalizedKeyword) ||
|
||||||
|
module.department.toLowerCase().includes(normalizedKeyword);
|
||||||
|
|
||||||
|
return matchesRole && matchesApp && matchesScope && matchesCountry && matchesOperator;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
46
money/src/model/data.test.js
Normal file
46
money/src/model/data.test.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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("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
32
money/src/model/format.js
Normal 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(),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
1166
money/src/styles/index.css
Normal file
1166
money/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,7 @@ export const PERMISSIONS = {
|
|||||||
coinSellerUpdate: "coin-seller:update",
|
coinSellerUpdate: "coin-seller:update",
|
||||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||||
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||||
|
moneyView: "money:view",
|
||||||
coinLedgerView: "coin-ledger:view",
|
coinLedgerView: "coin-ledger:view",
|
||||||
coinSellerLedgerView: "coin-seller-ledger:view",
|
coinSellerLedgerView: "coin-seller-ledger:view",
|
||||||
coinAdjustmentView: "coin-adjustment:view",
|
coinAdjustmentView: "coin-adjustment:view",
|
||||||
|
|||||||
@ -2,6 +2,48 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach } from "vitest";
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
installStoragePolyfill("localStorage");
|
||||||
|
installStoragePolyfill("sessionStorage");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function installStoragePolyfill(name) {
|
||||||
|
let storage;
|
||||||
|
try {
|
||||||
|
storage = window[name];
|
||||||
|
} catch {
|
||||||
|
storage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storage?.getItem && storage?.setItem && storage?.removeItem && storage?.clear) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map();
|
||||||
|
Object.defineProperty(window, name, {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clear() {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
getItem(key) {
|
||||||
|
const normalizedKey = String(key);
|
||||||
|
return store.has(normalizedKey) ? store.get(normalizedKey) : null;
|
||||||
|
},
|
||||||
|
key(index) {
|
||||||
|
return Array.from(store.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
get length() {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
removeItem(key) {
|
||||||
|
store.delete(String(key));
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
store.set(String(key), String(value));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,39 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
function redirectSubsystemEntry(req, res, next) {
|
||||||
|
const rawUrl = req.url || "";
|
||||||
|
const [pathname, query = ""] = rawUrl.split("?");
|
||||||
|
|
||||||
|
if (pathname === "/databi" || pathname === "/money") {
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.setHeader("Location", `${pathname}/${query ? `?${query}` : ""}`);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subsystemEntryPlugin() {
|
||||||
|
return {
|
||||||
|
name: "hyapp-subsystem-entry",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(redirectSubsystemEntry);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
server.middlewares.use(redirectSubsystemEntry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
admin: new URL("./index.html", import.meta.url).pathname,
|
admin: new URL("./index.html", import.meta.url).pathname,
|
||||||
databi: new URL("./databi/index.html", import.meta.url).pathname
|
databi: new URL("./databi/index.html", import.meta.url).pathname,
|
||||||
|
money: new URL("./money/index.html", import.meta.url).pathname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -37,7 +64,7 @@ export default defineConfig({
|
|||||||
"react/jsx-runtime"
|
"react/jsx-runtime"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [subsystemEntryPlugin(), react()],
|
||||||
server: {
|
server: {
|
||||||
port: 7001,
|
port: 7001,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user