财务系统和新BI

This commit is contained in:
local 2026-06-28 15:11:48 +08:00
parent 7858530fc8
commit f059b0c60c
34 changed files with 4570 additions and 80 deletions

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 数据大屏</title>
<title>HYApp 数据中心</title>
</head>
<body>
<div id="databi-root"></div>

View File

@ -7,6 +7,7 @@ import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
import { ReportOverview } from "./components/ReportOverview.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
@ -26,6 +27,7 @@ export function DatabiApp() {
const [countryId, setCountryId] = useState(0);
const [regionId, setRegionId] = useState("all");
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
const [viewMode, setViewMode] = useState("report");
const [activeScreen, setActiveScreen] = useState("overview");
const [gameId, setGameId] = useState("all");
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 showSkeleton = loading && !overview;
const isReportView = viewMode === "report";
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
}, []);
return (
<main className="databi-shell">
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
<main className={`databi-shell databi-shell--${viewMode}`}>
<section className={[
"databi-screen",
isReportView ? "databi-screen--report" : "databi-screen--bigscreen",
activeScreen === "selfGames" ? "databi-screen--self-game" : ""
].filter(Boolean).join(" ")}>
<DatabiHeader
activeScreen={activeScreen}
appCode={appCode}
@ -221,55 +228,75 @@ export function DatabiApp() {
timeZone={timeZone}
timeZoneOptions={TIME_ZONE_OPTIONS}
updatedAt={model.updatedAt}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{activeScreen === "selfGames" ? (
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
) : isReportView ? (
<ReportOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
) : (
<>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
))}
</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>
</>
<BigscreenOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
)}
</section>
{activeScreen === "overview" ? (
{activeScreen === "overview" && !isReportView ? (
<>
<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}
{activeScreen === "overview" && poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
{activeScreen === "overview" && trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
</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) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
@ -317,23 +344,26 @@ function countryBelongsToRegion(country, regionId) {
}
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;
}
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 {
...data,
country_breakdown: data.country_breakdown.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
};
})
country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown,
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
};
}

View File

@ -42,7 +42,8 @@ test("keeps current data visible during scheduled refresh", async () => {
await flushEffects();
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 () => {
vi.advanceTimersByTime(60_000);
@ -50,7 +51,7 @@ test("keeps current data visible during scheduled refresh", async () => {
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
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 () => {
@ -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 () => {
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
fetchSelfGameStatisticsOverview.mockResolvedValue({
@ -103,6 +133,9 @@ test("renders real-room robot gift cards in a separate row", async () => {
render(<DatabiApp />);
await flushEffects();
await act(async () => {
screen.getByRole("tab", { name: "大屏 BI" }).click();
});
const businessSection = screen.getByLabelText("业务指标");
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");

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

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
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";
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
@ -33,11 +34,13 @@ export function DatabiHeader({
onRangeChange,
onRegionChange,
onTimeZoneChange,
onViewModeChange,
range,
regionId,
regionOptions = defaultRegionOptions,
timeZone,
timeZoneOptions = TIME_ZONE_OPTIONS
timeZoneOptions = TIME_ZONE_OPTIONS,
viewMode = "report"
}) {
const [openControl, setOpenControl] = useState("");
const headerRef = useRef(null);
@ -77,10 +80,22 @@ export function DatabiHeader({
return (
<header className="databi-header" ref={headerRef}>
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true" />
<div>
<h1>App Data</h1>
<div className="databi-header-left">
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true">
<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>
<nav className="screen-switch" aria-label="数据大屏切换">

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

View File

@ -33,16 +33,18 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
const gameProfit = readNumber(source, "game_profit", "gameProfit");
const countryBreakdown = normalizeCountries(source, countryId);
const dailyCountryBreakdown = normalizeDailyCountryBreakdown(source);
const dailySeries = normalizeDailySeries(source);
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
return {
appCode,
businessKpis: [
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商转账金币", "coin") }),
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "coin") }),
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
@ -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") })
],
countryBreakdown,
dailyCountryBreakdown,
funnel: [
{ name: "活跃用户", rate: 1, value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
@ -105,6 +108,8 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
],
luckyGiftPools,
payoutDistribution,
reportCountryRows,
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
revenueSeries,
robotGiftKpis: [
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
@ -143,6 +148,15 @@ function readNumber(source, ...keys) {
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) {
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
for (const key of keys) {
@ -229,6 +243,131 @@ function normalizeCountries(source, countryId) {
.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) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const countryId = numberValue(item.country_id ?? item.countryId);
@ -238,26 +377,64 @@ function normalizeCountryRow(item, index, totalRecharge) {
const activeUsers = numberValue(item.active_users);
const paidUsers = numberValue(item.paid_users);
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
const gamePayout = readNumber(item, "game_payout", "gamePayout");
const gameRefund = readNumber(item, "game_refund", "gameRefund");
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover - gamePayout - gameRefund);
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover - luckyGiftPayout);
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
const superLuckyGiftProfit = readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
return {
active_users: activeUsers,
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
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_total: readOptionalNumber(item, "coin_total", "coinTotal"),
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
country: meta?.label || country,
country_id: countryId,
country_code: code || meta?.code || "",
flag: countryFlag(code || meta?.code),
game_payout: gamePayout,
game_profit: gameProfit,
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? ratio(gameProfit, gameTurnover),
game_refund: gameRefund,
game_turnover: gameTurnover,
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
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),
paid_users: paidUsers,
payer_rate: ratio(paidUsers, activeUsers),
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
recharge_usd_minor: recharge,
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
: ratio(rechargeUsers, activeUsers),
recharge_users: rechargeUsers,
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
share: recharge / totalRecharge,
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
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)
};
}
@ -300,6 +477,7 @@ function normalizeDailySeries(source) {
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
coin_seller: 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"),
game_profit: gameProfit,
game_turnover: gameTurnover,
@ -317,6 +495,7 @@ function normalizeDailySeries(source) {
paid_users: readNumber(item, "paid_users", "paidUsers"),
recharge_usd_minor: recharge,
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 || "",
total: recharge,
user_recharge_usd_minor: userRecharge

View File

@ -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", () => {
const model = createDashboardModel({
coin_seller_recharge_usd_minor: 200,
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
salary_transfer_coin: 88_000,
country_breakdown: [
{
coin_seller_recharge_usd_minor: 200,
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
country: "阿富汗",
google_recharge_usd_minor: 150,
recharge_usd_minor: 150
recharge_usd_minor: 150,
salary_transfer_coin: 88_000
}
],
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" } });
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.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", () => {
@ -124,6 +128,55 @@ test("uses registration count instead of legacy visitors in country breakdown",
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) {
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
}

View File

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

View File

@ -50,24 +50,11 @@
box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
}
.brand-emblem::before {
width: 19px;
height: 19px;
border: 2px solid #27e4f5;
border-radius: 50%;
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-emblem svg {
width: 23px;
height: 23px;
color: #27e4f5;
filter: drop-shadow(0 0 8px rgba(39, 228, 245, 0.38));
}
.brand-lockup h1 {

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

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 财务系统</title>
</head>
<body>
<div id="money-root"></div>
<script type="module" src="/money/src/main.jsx"></script>
</body>
</html>

234
money/src/MoneyApp.jsx Normal file
View 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
View 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");
}
}

View File

@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
import { BarChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([BarChart, GridComponent, LegendComponent, LineChart, TooltipComponent, CanvasRenderer]);
export function EChart({ className = "", option }) {
const elementRef = useRef(null);
const chartRef = useRef(null);
const optionRef = useRef(option);
useEffect(() => {
optionRef.current = option;
chartRef.current?.setOption(option, true);
}, [option]);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return undefined;
}
const chart = echarts.init(element, null, { renderer: "canvas" });
chartRef.current = chart;
chart.setOption(optionRef.current, true);
const resize = () => chart.resize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
observer?.observe(element);
window.addEventListener("resize", resize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", resize);
chart.dispose();
chartRef.current = null;
};
}, []);
return <div className={["money-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
}

View File

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

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

View File

@ -0,0 +1,23 @@
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function MoneyKpis({ summary }) {
const cards = [
["总充值", formatUsd(summary.rechargeUsd), `ARPPU ${formatUsd(summary.arppuUsd)}`, "success"],
["App 流水", formatUsd(summary.revenueUsd), `毛利 ${formatUsd(summary.grossProfitUsd)}`, "primary"],
["存量工资", formatUsd(summary.salaryUsd), `日目标 ${formatPercent(summary.dayProgress)}`, "warning"],
["新增 / 日活", formatNumber(summary.newUsers), `DAU ${formatNumber(summary.dau)}`, "info"],
["支付链接转化", formatPercent(summary.linkConversion), `${formatNumber(summary.paidLinkCount)} / ${formatNumber(summary.totalLinkCount)} 已支付`, "neutral"]
];
return (
<section className="money-kpis">
{cards.map(([label, value, sub, tone]) => (
<article className={`money-kpi money-kpi--${tone}`} key={label}>
<span>{label}</span>
<strong>{value}</strong>
<small>{sub}</small>
</article>
))}
</section>
);
}

View File

@ -0,0 +1,85 @@
import { MoneyKpis } from "./MoneyKpis.jsx";
import { MoneyTrendPanel } from "./MoneyTrendPanel.jsx";
import { formatDateTime, formatNumber, formatPercent, formatUsd } from "../format.js";
import { paymentStatusLabel } from "../data.js";
export function MoneyOverview({ links, modules, summary }) {
const topModules = [...modules].sort((a, b) => b.rechargeUsd - a.rechargeUsd).slice(0, 4);
const recentLinks = links.slice(0, 4);
return (
<>
<MoneyKpis summary={summary} />
<MoneyTrendPanel summary={summary} />
<section className="money-workbench-grid">
<article className="money-panel money-focus-panel">
<div className="money-panel-head">
<div>
<h2>高价值模块</h2>
<span>Top 4</span>
</div>
<strong>{formatUsd(summary.grossProfitUsd)}</strong>
</div>
<div className="money-focus-list">
{topModules.map((module, index) => (
<div className="money-focus-row" key={module.id}>
<b>{String(index + 1).padStart(2, "0")}</b>
<span>
<strong>{module.moduleName}</strong>
<small>{module.operatorName} · {module.countryName}</small>
</span>
<em>{formatUsd(module.rechargeUsd)}</em>
</div>
))}
</div>
</article>
<article className="money-panel money-focus-panel">
<div className="money-panel-head">
<div>
<h2>支付链接动态</h2>
<span>Last 4</span>
</div>
<strong>{formatPercent(summary.linkConversion)}</strong>
</div>
<div className="money-focus-list">
{recentLinks.map((link) => (
<div className="money-focus-row" key={link.id}>
<b>{link.countryCode}</b>
<span>
<strong>{link.moduleName}</strong>
<small>{formatDateTime(link.createdAtMs)} · {paymentStatusLabel(link.status)}</small>
</span>
<em>{formatUsd(link.amountUsd)}</em>
</div>
))}
</div>
</article>
<article className="money-panel money-focus-panel money-focus-panel--wide">
<div className="money-panel-head">
<div>
<h2>经营信号</h2>
<span>D0</span>
</div>
<strong>{formatNumber(summary.newUsers)}</strong>
</div>
<div className="money-signal-grid">
<Signal label="D目标" value={formatPercent(summary.dayProgress)} tone={summary.dayProgress >= 100 ? "success" : "warning"} />
<Signal label="新增质量" value={`次留 ${formatPercent(summary.retention1)}`} tone="info" />
<Signal label="活跃规模" value={`DAU ${formatNumber(summary.dau)}`} tone="neutral" />
</div>
</article>
</section>
</>
);
}
function Signal({ label, tone, value }) {
return (
<div className={`money-signal money-signal--${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}

View File

@ -0,0 +1,101 @@
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
import DashboardCustomizeOutlined from "@mui/icons-material/DashboardCustomizeOutlined";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import TableRowsOutlined from "@mui/icons-material/TableRowsOutlined";
import { formatPercent, formatUsd } from "../format.js";
export const MONEY_VIEWS = [
{
id: "overview",
label: "财务总览",
title: "财务总览",
Icon: DashboardCustomizeOutlined
},
{
id: "modules",
label: "模块绩效",
title: "模块绩效",
Icon: TableRowsOutlined
},
{
id: "operators",
label: "运营绩效",
title: "运营绩效",
Icon: GroupsOutlined
},
{
id: "payments",
label: "支付链接",
title: "支付链接",
Icon: AddLinkOutlined
},
{
id: "countries",
label: "国家渠道",
title: "国家渠道",
Icon: PublicOutlined
}
];
export function MoneySidebar({ activeOperator, activeView, mode, onModeChange, onViewChange, session, summary }) {
return (
<aside className="money-sidebar">
<div className="money-sidebar-brand">
<div className="money-sidebar-mark">
<AccountBalanceWalletOutlined fontSize="small" />
</div>
<div>
<strong>Money OS</strong>
</div>
</div>
<nav className="money-sidebar-nav" aria-label="财务系统菜单">
{MONEY_VIEWS.map(({ Icon, id, label }) => (
<button
aria-current={activeView === id ? "page" : undefined}
className={activeView === id ? "is-active" : ""}
key={id}
onClick={() => onViewChange(id)}
type="button"
>
<Icon fontSize="small" />
<span>
<strong>{label}</strong>
</span>
</button>
))}
</nav>
<section className="money-sidebar-card">
<span>MODE</span>
<div className="money-mode-switch" role="tablist" aria-label="财务视角">
<button className={mode === "finance" ? "is-active" : ""} onClick={() => onModeChange("finance")} role="tab" type="button">
FIN
</button>
<button className={mode === "operator" ? "is-active" : ""} onClick={() => onModeChange("operator")} role="tab" type="button">
OPS
</button>
</div>
<strong>{mode === "operator" ? activeOperator.label : "ALL OPS"}</strong>
<small>{mode === "operator" ? activeOperator.department : "FINANCE"}</small>
</section>
<section className="money-sidebar-metric">
<PaymentsOutlined fontSize="small" />
<div>
<span>D0 充值</span>
<strong>{formatUsd(summary.rechargeUsd)}</strong>
<small>CVR {formatPercent(summary.linkConversion)}</small>
</div>
</section>
<footer className="money-sidebar-user">
<span>{session?.user?.username || "admin"}</span>
<small>money:view</small>
</footer>
</aside>
);
}

View File

@ -0,0 +1,101 @@
import { EChart } from "../charts/EChart.jsx";
import { trendLabels } from "../data.js";
import { formatPercent, formatUsd } from "../format.js";
export function MoneyTrendPanel({ summary }) {
return (
<section className="money-analysis">
<article className="money-panel money-panel--chart">
<PanelHead label="充值与流水趋势" meta="近 7 日" value={formatUsd(summary.revenueUsd)} />
<EChart className="money-trend-chart" option={createTrendOption(summary)} />
</article>
<article className="money-panel money-panel--targets">
<PanelHead label="目标达成" meta="当日 / 当月" value={formatPercent(summary.monthProgress)} />
<ProgressRow label="当日充值" percent={summary.dayProgress} value={`${formatUsd(summary.rechargeUsd)} / ${formatUsd(summary.dayTargetUsd)}`} />
<ProgressRow label="当月充值" percent={summary.monthProgress} value={`${formatUsd(summary.monthActualUsd)} / ${formatUsd(summary.monthTargetUsd)}`} />
<div className="money-retention">
<span>次留 {formatPercent(summary.retention1)}</span>
<span>7 {formatPercent(summary.retention7)}</span>
<span>30 {formatPercent(summary.retention30)}</span>
</div>
</article>
</section>
);
}
export function PanelHead({ label, meta, value }) {
return (
<div className="money-panel-head">
<div>
<h2>{label}</h2>
<span>{meta}</span>
</div>
<strong>{value}</strong>
</div>
);
}
function ProgressRow({ label, percent, value }) {
return (
<div className="money-progress">
<div>
<span>{label}</span>
<strong>{formatPercent(percent)}</strong>
</div>
<i>
<b style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
</i>
<small>{value}</small>
</div>
);
}
function createTrendOption(summary) {
return {
animationDuration: 520,
color: ["#2563eb", "#16a34a"],
grid: { bottom: 30, containLabel: true, left: 6, right: 16, top: 24 },
legend: {
bottom: 0,
icon: "roundRect",
itemHeight: 8,
itemWidth: 16,
textStyle: { color: "#64748b" }
},
tooltip: {
backgroundColor: "#ffffff",
borderColor: "#d8e0ec",
trigger: "axis",
textStyle: { color: "#0f172a" },
valueFormatter: (value) => formatUsd(value)
},
xAxis: {
axisLine: { lineStyle: { color: "rgba(100, 116, 139, 0.18)" } },
axisTick: { show: false },
data: trendLabels,
type: "category"
},
yAxis: {
axisLabel: { formatter: (value) => `${Math.round(value / 1000)}k` },
splitLine: { lineStyle: { color: "rgba(100, 116, 139, 0.16)" } },
type: "value"
},
series: [
{
areaStyle: { opacity: 0.12 },
data: summary.rechargeTrend,
name: "充值",
smooth: true,
symbol: "circle",
symbolSize: 6,
type: "line"
},
{
barMaxWidth: 18,
data: summary.revenueTrend,
name: "流水",
type: "bar"
}
]
};
}

View File

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

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

View File

@ -0,0 +1,136 @@
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function PerformanceTable({ meta = "", modules, summary, title = "运营模块绩效" }) {
const rows = [createTotalRow(summary), ...modules];
return (
<section className="money-panel money-table-panel">
<div className="money-table-head">
<div>
<h2>{title}</h2>
<span>{meta || `${modules.length} 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
View 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
View File

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

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

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { MoneyApp } from "./MoneyApp.jsx";
import "./styles/index.css";
createRoot(document.getElementById("money-root")).render(
<React.StrictMode>
<MoneyApp />
</React.StrictMode>
);

402
money/src/model/data.js Normal file
View 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())}`;
}

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

@ -0,0 +1,32 @@
export function formatUsd(value, options = {}) {
const digits = options.compact ? 1 : 2;
return `US$ ${Number(value || 0).toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: options.minimumFractionDigits ?? 0,
})}`;
}
export function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
export function formatPercent(value, digits = 1) {
return `${Number(value || 0).toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
})}%`;
}
export function formatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
const pad = (part) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes(),
)}`;
}

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

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@ export const PERMISSIONS = {
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
moneyView: "money:view",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
coinAdjustmentView: "coin-adjustment:view",

View File

@ -2,6 +2,48 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
installStoragePolyfill("localStorage");
installStoragePolyfill("sessionStorage");
afterEach(() => {
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));
},
},
});
}

View File

@ -1,12 +1,39 @@
import { defineConfig } from "vite";
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({
build: {
rollupOptions: {
input: {
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"
]
},
plugins: [react()],
plugins: [subsystemEntryPlugin(), react()],
server: {
port: 7001,
proxy: {