财务系统和新BI
This commit is contained in:
parent
7858530fc8
commit
f059b0c60c
@ -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>
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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("真人房机器人送礼指标");
|
||||
|
||||
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
|
||||
import { niceAxis } from "./createMetricTrendOption.js";
|
||||
|
||||
export function createReportRevenueOption(series) {
|
||||
const labels = series.map((item) => item.label);
|
||||
const channelValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller));
|
||||
const totalValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total));
|
||||
const channelAxis = niceAxis(channelValues);
|
||||
const totalAxis = niceAxis(totalValues);
|
||||
|
||||
return {
|
||||
animationDuration: 520,
|
||||
color: ["#1688d9", "#18a389", "#6377e8", "#172033"],
|
||||
grid: { bottom: 30, left: 54, right: 54, top: 42 },
|
||||
legend: [
|
||||
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 18, textStyle: { color: "#5f6f86", fontSize: 12 }, top: 0 },
|
||||
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#172033", fontSize: 12 }, top: 0 }
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e2ed",
|
||||
extraCssText: "box-shadow: 0 14px 34px rgba(15, 23, 42, .14);",
|
||||
textStyle: { color: "#172033" },
|
||||
trigger: "axis"
|
||||
},
|
||||
xAxis: {
|
||||
axisLabel: { color: "#6b7a90", margin: 10 },
|
||||
axisLine: { lineStyle: { color: "#dbe5ef" } },
|
||||
axisTick: { show: false },
|
||||
boundaryGap: true,
|
||||
data: labels,
|
||||
type: "category"
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||
axisTick: { show: false },
|
||||
interval: channelAxis.interval,
|
||||
max: channelAxis.max,
|
||||
min: 0,
|
||||
splitLine: { lineStyle: { color: "rgba(104, 123, 148, 0.16)", type: "dashed" } },
|
||||
type: "value"
|
||||
},
|
||||
{
|
||||
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||
axisTick: { show: false },
|
||||
interval: totalAxis.interval,
|
||||
max: totalAxis.max,
|
||||
min: 0,
|
||||
splitLine: { show: false },
|
||||
type: "value"
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{ barGap: "-100%", barWidth: 26, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
|
||||
{ barWidth: 26, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
|
||||
{ barWidth: 26, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
|
||||
{
|
||||
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
|
||||
lineStyle: { color: "#172033", shadowBlur: 8, shadowColor: "rgba(23, 32, 51, .14)", width: 3 },
|
||||
name: "总计 (USD)",
|
||||
smooth: 0.42,
|
||||
symbol: "circle",
|
||||
symbolSize: 7,
|
||||
itemStyle: { borderColor: "#ffffff", borderWidth: 2, color: "#172033" },
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
z: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import 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="数据大屏切换">
|
||||
|
||||
537
databi/src/components/ReportOverview.jsx
Normal file
537
databi/src/components/ReportOverview.jsx
Normal file
@ -0,0 +1,537 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { EChart } from "../charts/EChart.jsx";
|
||||
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
||||
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
|
||||
|
||||
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
|
||||
|
||||
const reportTabs = [
|
||||
{ key: "country", label: "国家经营报表" },
|
||||
{ key: "revenue", label: "收入趋势" },
|
||||
{ key: "gift", label: "礼物消费" },
|
||||
{ key: "lucky", label: "幸运礼物" },
|
||||
{ key: "game", label: "游戏流水" },
|
||||
{ key: "robot", label: "机器人送礼" }
|
||||
];
|
||||
|
||||
const countryColumns = [
|
||||
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false },
|
||||
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
|
||||
{ key: "registrations", label: "注册", render: (row) => formatOptionalCoin(row.registrations), sortable: false },
|
||||
{ key: "active_users", label: "活跃用户", render: (row) => formatOptionalCoin(row.active_users), sortable: false },
|
||||
{ key: "paid_users", label: "付费用户", render: (row) => formatOptionalCoin(row.paid_users), sortable: false },
|
||||
{ key: "recharge_users", label: "充值用户", render: (row) => formatOptionalCoin(row.recharge_users), sortable: false },
|
||||
{ key: "recharge_usd_minor", label: "总充值", render: (row) => formatOptionalMoney(row.recharge_usd_minor), sortable: false },
|
||||
{ key: "new_user_recharge_usd_minor", label: "新用户充值", render: (row) => formatOptionalMoney(row.new_user_recharge_usd_minor), sortable: false },
|
||||
{ key: "coin_seller_recharge_usd_minor", label: "币商充值", render: (row) => formatOptionalMoney(row.coin_seller_recharge_usd_minor), sortable: false },
|
||||
{ key: "coin_seller_stock_coin", label: "币商充值金币", render: (row) => formatOptionalCoin(row.coin_seller_stock_coin), sortable: false },
|
||||
{ key: "coin_seller_transfer_coin", label: "币商出货金币", render: (row) => formatOptionalCoin(row.coin_seller_transfer_coin), sortable: false },
|
||||
{ key: "google_recharge_usd_minor", label: "谷歌充值", render: (row) => formatOptionalMoney(row.google_recharge_usd_minor), sortable: false },
|
||||
{ key: "mifapay_recharge_usd_minor", label: "三方充值", render: (row) => formatOptionalMoney(row.mifapay_recharge_usd_minor), sortable: false },
|
||||
{ key: "game_turnover", label: "游戏流水/利润率", render: (row) => <FlowRateCell rate={row.game_profit_rate} value={row.game_turnover} />, sortable: false },
|
||||
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
|
||||
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
|
||||
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
|
||||
{ key: "coin_total", label: "金币数量(总和)", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
|
||||
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
|
||||
{ key: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false },
|
||||
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
|
||||
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
|
||||
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
|
||||
{ key: "avg_mic_online_ms", label: "平均麦上时间", render: (row) => formatDuration(row.avg_mic_online_ms), sortable: false },
|
||||
{ key: "mic_online_ms", label: "麦上总时长", render: (row) => formatDuration(row.mic_online_ms), sortable: false },
|
||||
{ key: "arpu_usd_minor", label: "ARPU", render: (row) => formatOptionalMoney(row.arpu_usd_minor), sortable: false },
|
||||
{ key: "arppu_usd_minor", label: "ARPPU", render: (row) => formatOptionalMoney(row.arppu_usd_minor), sortable: false },
|
||||
{ key: "payer_rate", label: "付费转化率", render: (row) => formatOptionalPercent(row.payer_rate), sortable: false },
|
||||
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
|
||||
];
|
||||
|
||||
const revenueColumns = [
|
||||
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||||
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
|
||||
{ key: "mifapay", label: "MifaPay", render: (row) => formatMoneyFull(row.mifapay) },
|
||||
{ key: "coin_seller", label: "币商", render: (row) => formatMoneyFull(row.coin_seller) },
|
||||
{ key: "total", label: "渠道合计", render: (row) => formatMoneyFull(row.total) },
|
||||
{ key: "lineTotal", label: "总计", render: (row) => formatMoneyFull(row.lineTotal ?? row.total) }
|
||||
];
|
||||
|
||||
const giftColumns = [
|
||||
{ align: "left", key: "name", label: "礼物", render: (row) => row.name, sortValue: (row) => row.name, type: "text" },
|
||||
{ key: "value", label: "金币消费", render: (row) => formatCoin(row.value) },
|
||||
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) },
|
||||
{ key: "trend_rate", label: "近 7 日", render: (row) => <TrendValue value={row.trend_rate} /> }
|
||||
];
|
||||
|
||||
const luckyColumns = [
|
||||
{ align: "left", key: "pool_id", label: "奖池", render: (row) => row.pool_id, sortValue: (row) => row.pool_id, type: "text" },
|
||||
{ key: "turnover", label: "流水", render: (row) => formatCoin(row.turnover) },
|
||||
{ key: "payout", label: "返奖", render: (row) => formatCoin(row.payout) },
|
||||
{ key: "profit", label: "利润", render: (row) => formatCoin(row.profit) },
|
||||
{ key: "payout_rate", label: "返奖率", render: (row) => formatPercent(row.payout_rate) },
|
||||
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) }
|
||||
];
|
||||
|
||||
const gameColumns = [
|
||||
{ align: "left", key: "game_id", label: "游戏", render: (row) => row.game_label || row.game_id, sortValue: (row) => row.game_label || row.game_id, type: "text" },
|
||||
{ key: "turnover_coin", label: "流水 (金币)", render: (row) => formatCoin(row.turnover_coin) },
|
||||
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) },
|
||||
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) }
|
||||
];
|
||||
|
||||
const metricColumns = [
|
||||
{ align: "left", key: "label", label: "指标", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||||
{ key: "value", label: "数值", render: (row) => row.value, sortable: false },
|
||||
{ key: "unit", label: "单位", render: (row) => row.unit || "--", sortable: false },
|
||||
{ key: "caption", label: "对比口径", render: (row) => row.caption || "--", sortable: false },
|
||||
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
|
||||
];
|
||||
|
||||
export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||||
const [activeTab, setActiveTab] = useState("country");
|
||||
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
||||
|
||||
return (
|
||||
<section className="databi-report" aria-label="数据报表">
|
||||
<section className="report-metric-grid" aria-label="核心经营指标">
|
||||
{summaryCards.map((card) => (
|
||||
<ReportMetricCard card={card} key={card.title} loading={loading} onOpenTrend={onMetricTrend} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="report-workbench">
|
||||
<div className="report-tabs" role="tablist" aria-label="报表切换">
|
||||
{reportTabs.map((tab) => (
|
||||
<button
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={activeTab === tab.key ? "is-active" : ""}
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="report-tab-panel" role="tabpanel">
|
||||
{activeTab === "country" ? <CountryReport loading={loading} rows={model.reportCountryRows} /> : null}
|
||||
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
|
||||
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
|
||||
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
|
||||
{activeTab === "game" ? <GameReport loading={loading} metrics={model.businessKpis} rows={model.gameRanking} /> : null}
|
||||
{activeTab === "robot" ? <RobotReport loading={loading} rows={model.robotGiftKpis} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportMetricCard({ card, loading, onOpenTrend }) {
|
||||
const clickable = Boolean(card.trendMetric?.trend?.length && onOpenTrend);
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="report-metric-card is-loading" aria-busy="true">
|
||||
<span className="skeleton-block report-skeleton-title" />
|
||||
<span className="skeleton-block report-skeleton-value" />
|
||||
<span className="skeleton-block report-skeleton-line" />
|
||||
<span className="skeleton-block report-skeleton-line report-skeleton-line--short" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={["report-metric-card", clickable ? "report-metric-card--clickable" : ""].filter(Boolean).join(" ")}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onOpenTrend(card.trendMetric) : undefined}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpenTrend(card.trendMetric);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<div className="report-metric-head">
|
||||
<span>{card.title}</span>
|
||||
{card.delta ? <DeltaText value={card.delta} /> : null}
|
||||
</div>
|
||||
<strong>{card.value}</strong>
|
||||
<div className="report-metric-detail-grid">
|
||||
{card.details.filter((detail) => detail.label !== card.title).map((detail) => (
|
||||
<span className="report-metric-detail" key={detail.label}>
|
||||
<small>{detail.label}</small>
|
||||
<b>{detail.value}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryReport({ loading, rows }) {
|
||||
return (
|
||||
<ReportPanel>
|
||||
<ReportTable columns={countryColumns} loading={loading} preserveOrder rows={rows} wide />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function RevenueReport({ loading, rows }) {
|
||||
return (
|
||||
<ReportPanel>
|
||||
{loading ? (
|
||||
<div className="report-chart-skeleton" aria-busy="true">
|
||||
<span className="skeleton-block report-skeleton-line" />
|
||||
<span className="skeleton-block report-skeleton-chart" />
|
||||
</div>
|
||||
) : rows.length ? (
|
||||
<EChart className="report-revenue-chart" option={createReportRevenueOption(rows)} />
|
||||
) : (
|
||||
<div className="report-empty">当前无数据</div>
|
||||
)}
|
||||
<ReportTable columns={revenueColumns} defaultSort={{ direction: "asc", key: "label", type: "text" }} loading={loading} rows={rows} />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftReport({ loading, metrics, rows }) {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.value || 0), 0) || 1;
|
||||
const giftRows = rows.map((row) => ({ ...row, share: Number(row.value || 0) / total }));
|
||||
return (
|
||||
<ReportPanel>
|
||||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("礼物") || item.label.includes("币商"))} />
|
||||
<ReportTable columns={giftColumns} defaultSort={{ direction: "desc", key: "value", type: "number" }} loading={loading} rows={giftRows} />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function LuckyReport({ loading, metrics, onOpenLuckyGiftPools, rows }) {
|
||||
return (
|
||||
<ReportPanel
|
||||
action={(
|
||||
<button className="report-panel-action" disabled={!rows.length} type="button" onClick={onOpenLuckyGiftPools}>
|
||||
查看奖池明细
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("幸运礼物"))} />
|
||||
<ReportTable columns={luckyColumns} defaultSort={{ direction: "desc", key: "turnover", type: "number" }} loading={loading} rows={rows} />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function GameReport({ loading, metrics, rows }) {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.turnover_coin || 0), 0) || 1;
|
||||
const gameRows = rows.map((row) => ({ ...row, share: Number(row.turnover_coin || 0) / total }));
|
||||
return (
|
||||
<ReportPanel>
|
||||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("游戏"))} />
|
||||
<ReportTable columns={gameColumns} defaultSort={{ direction: "desc", key: "turnover_coin", type: "number" }} loading={loading} rows={gameRows} />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotReport({ loading, rows }) {
|
||||
return (
|
||||
<ReportPanel>
|
||||
<ReportMetricTable loading={loading} rows={rows} />
|
||||
</ReportPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPanel({ action, children }) {
|
||||
return (
|
||||
<section className="report-panel">
|
||||
{action ? <div className="report-panel-head">{action}</div> : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportMetricTable({ loading, rows }) {
|
||||
return (
|
||||
<div className="report-metric-table-block">
|
||||
<ReportTable columns={metricColumns} loading={loading} rows={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, rows, wide = false }) {
|
||||
const sortableDefault = defaultSort || firstSortableColumn(columns);
|
||||
const [sortState, setSortState] = useState(sortableDefault);
|
||||
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
|
||||
|
||||
const handleSort = (column) => {
|
||||
if (preserveOrder || column.sortable === false) {
|
||||
return;
|
||||
}
|
||||
setSortState((current) => ({
|
||||
direction: current?.key === column.key && current.direction === "desc" ? "asc" : "desc",
|
||||
key: column.key,
|
||||
type: column.type || "number"
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="report-table-wrap">
|
||||
<table className={["report-table", wide ? "report-table--wide" : ""].filter(Boolean).join(" ")}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{columns.map((column) => {
|
||||
const isSortable = !preserveOrder && column.sortable !== false;
|
||||
const isActive = sortState?.key === column.key;
|
||||
return (
|
||||
<th className={isSortable ? "is-sortable" : ""} key={column.key}>
|
||||
{isSortable ? (
|
||||
<button
|
||||
className={["report-sort-button", column.align === "left" ? "report-sort-button--left" : "", isActive ? "is-active" : ""].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={() => handleSort(column)}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<i>{isActive ? (sortState.direction === "asc" ? "↑" : "↓") : "↕"}</i>
|
||||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
|
||||
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
|
||||
{!loading && sortedRows.map((row, index) => (
|
||||
<tr className={reportRowClass(row)} key={reportRowKey(row, index)}>
|
||||
<td>{reportRowIndex(row, index)}</td>
|
||||
{columns.map((column) => (
|
||||
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryCell({ row }) {
|
||||
return (
|
||||
<span className="report-country-cell">
|
||||
{row.flag ? <i>{row.flag}</i> : null}
|
||||
<span>{row.country}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DateCell({ row }) {
|
||||
return <span className="report-date-cell">{row.date_label || "--"}</span>;
|
||||
}
|
||||
|
||||
function FlowRateCell({ rate, value }) {
|
||||
if (isMissing(value) && isMissing(rate)) {
|
||||
return <span className="report-placeholder">--</span>;
|
||||
}
|
||||
return (
|
||||
<span className="report-pair-cell">
|
||||
<b>{formatOptionalCoin(value)}</b>
|
||||
<small>{formatOptionalPercent(rate)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendValue({ value }) {
|
||||
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
|
||||
}
|
||||
|
||||
function DeltaText({ value }) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return <span className="report-delta report-delta--empty">--</span>;
|
||||
}
|
||||
return <span className={text.startsWith("-") ? "report-delta report-delta--down" : "report-delta"}>{text.startsWith("-") ? "↓" : "↑"} {text.replace(/^[-+]/, "")}</span>;
|
||||
}
|
||||
|
||||
function ReportSkeletonRows({ colSpan }) {
|
||||
return Array.from({ length: 6 }).map((_, index) => (
|
||||
<tr className="report-table-skeleton-row" key={index}>
|
||||
<td colSpan={colSpan}><span className="skeleton-block report-skeleton-line" /></td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
function ReportEmptyRow({ colSpan }) {
|
||||
return (
|
||||
<tr className="report-table-empty-row">
|
||||
<td colSpan={colSpan}>当前无数据</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function createSummaryCards(model) {
|
||||
const totalRecharge = model.kpis[0] || {};
|
||||
const rechargeSplit = model.kpis[1]?.subMetrics || [];
|
||||
const newUsers = model.kpis[2] || {};
|
||||
const activeUsers = model.kpis[3] || {};
|
||||
const paidSplit = model.kpis[4]?.subMetrics || [];
|
||||
const arpu = model.kpis[5] || {};
|
||||
const arppu = model.kpis[6] || {};
|
||||
const payerRate = model.sideMetrics?.find((item) => item.label === "付费转化率") || {};
|
||||
|
||||
return [
|
||||
{
|
||||
delta: totalRecharge.delta,
|
||||
details: [
|
||||
{ label: "用户充值", value: rechargeSplit[0]?.value || "--" },
|
||||
{ label: "新用户充值", value: rechargeSplit[1]?.value || "--" }
|
||||
],
|
||||
title: "总充值",
|
||||
trendMetric: totalRecharge,
|
||||
value: totalRecharge.value || "--",
|
||||
valueLabel: "总充值"
|
||||
},
|
||||
{
|
||||
delta: activeUsers.delta,
|
||||
details: [
|
||||
{ label: "新增用户", value: newUsers.value || "--" },
|
||||
{ label: "活跃用户", value: activeUsers.value || "--" }
|
||||
],
|
||||
title: "活跃用户",
|
||||
trendMetric: activeUsers,
|
||||
value: activeUsers.value || "--",
|
||||
valueLabel: "活跃用户"
|
||||
},
|
||||
{
|
||||
delta: paidSplit[0]?.delta,
|
||||
details: [
|
||||
{ label: "付费用户", value: paidSplit[0]?.value || "--" },
|
||||
{ label: "新增付费", value: paidSplit[1]?.value || "--" }
|
||||
],
|
||||
title: "付费转化率",
|
||||
trendMetric: model.kpis[4],
|
||||
value: payerRate.value || "--",
|
||||
valueLabel: "付费转化率"
|
||||
},
|
||||
{
|
||||
delta: arpu.delta,
|
||||
details: [
|
||||
{ label: "ARPU", value: arpu.value || "--" },
|
||||
{ label: "ARPPU", value: arppu.value || "--" }
|
||||
],
|
||||
title: "ARPPU",
|
||||
trendMetric: arpu,
|
||||
value: arppu.value || "--",
|
||||
valueLabel: "ARPPU"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function firstSortableColumn(columns) {
|
||||
const column = columns.find((item) => item.sortable !== false);
|
||||
return column ? { direction: column.type === "text" ? "asc" : "desc", key: column.key, type: column.type || "number" } : null;
|
||||
}
|
||||
|
||||
function sortRows(rows, sortState, columns) {
|
||||
if (!sortState?.key) {
|
||||
return rows;
|
||||
}
|
||||
const column = columns.find((item) => item.key === sortState.key);
|
||||
if (!column) {
|
||||
return rows;
|
||||
}
|
||||
const multiplier = sortState.direction === "asc" ? 1 : -1;
|
||||
return rows
|
||||
.map((row, index) => ({ index, row }))
|
||||
.sort((left, right) => {
|
||||
const result = compareValues(readSortValue(left.row, column), readSortValue(right.row, column), sortState.type);
|
||||
return result === 0 ? left.index - right.index : result * multiplier;
|
||||
})
|
||||
.map(({ row }) => row);
|
||||
}
|
||||
|
||||
function readSortValue(row, column) {
|
||||
return column.sortValue ? column.sortValue(row) : row[column.key];
|
||||
}
|
||||
|
||||
function compareValues(leftValue, rightValue, type) {
|
||||
if (type === "text") {
|
||||
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
|
||||
}
|
||||
const leftNumber = Number(leftValue);
|
||||
const rightNumber = Number(rightValue);
|
||||
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(leftNumber)) {
|
||||
return -1;
|
||||
}
|
||||
if (!Number.isFinite(rightNumber)) {
|
||||
return 1;
|
||||
}
|
||||
return leftNumber - rightNumber;
|
||||
}
|
||||
|
||||
function formatSignedPercent(value) {
|
||||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||
return "--";
|
||||
}
|
||||
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
|
||||
}
|
||||
|
||||
function reportRowKey(row, index) {
|
||||
return [row.row_id, row.country, row.name, row.label, row.pool_id, row.game_id, row.platform_code, row.value, index]
|
||||
.filter((item) => item !== undefined && item !== null && item !== "")
|
||||
.join(":");
|
||||
}
|
||||
|
||||
function reportRowClass(row) {
|
||||
if (row.row_type === "total") {
|
||||
return "report-row-total";
|
||||
}
|
||||
if (row.row_type === "date_total") {
|
||||
return "report-row-date-total";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function reportRowIndex(row, index) {
|
||||
if (row.row_type === "total") {
|
||||
return "总";
|
||||
}
|
||||
if (row.row_type === "date_total") {
|
||||
return "日";
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function formatOptionalMoney(value) {
|
||||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatMoneyFull(value);
|
||||
}
|
||||
|
||||
function formatOptionalCoin(value) {
|
||||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatCoin(value);
|
||||
}
|
||||
|
||||
function formatOptionalPercent(value) {
|
||||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatPercent(value);
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
if (isMissing(value)) {
|
||||
return <span className="report-placeholder">--</span>;
|
||||
}
|
||||
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function isMissing(value) {
|
||||
return value === undefined || value === null || Number.isNaN(Number(value));
|
||||
}
|
||||
@ -33,16 +33,18 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
||||
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
|
||||
const 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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -5,4 +5,5 @@
|
||||
@import "./panels.css";
|
||||
@import "./charts.css";
|
||||
@import "./tables.css";
|
||||
@import "./report.css";
|
||||
@import "./responsive.css";
|
||||
|
||||
@ -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 {
|
||||
|
||||
713
databi/src/styles/report.css
Normal file
713
databi/src/styles/report.css
Normal file
@ -0,0 +1,713 @@
|
||||
.databi-header-left {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.view-mode-switch {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(61, 141, 190, 0.42);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 28, 47, 0.72);
|
||||
}
|
||||
|
||||
.view-mode-switch button {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9eb7ce;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.view-mode-switch button:hover,
|
||||
.view-mode-switch button.is-active {
|
||||
background: rgba(37, 216, 245, 0.18);
|
||||
color: #e9f6ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
|
||||
}
|
||||
|
||||
.databi-screen--report {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.databi-shell--report {
|
||||
min-width: 1180px;
|
||||
min-height: 100vh;
|
||||
padding: 0 24px 28px;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .databi-header {
|
||||
height: auto;
|
||||
min-height: 72px;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
row-gap: 10px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e4ebf3;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.databi-shell--report .header-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.databi-shell--report .brand-emblem {
|
||||
border-color: #b9dcf4;
|
||||
background: #eef8ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(22, 136, 217, 0.04);
|
||||
}
|
||||
|
||||
.databi-shell--report .brand-emblem svg {
|
||||
color: #1688d9;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.databi-shell--report .brand-lockup h1 {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .view-mode-switch,
|
||||
.databi-shell--report .screen-switch,
|
||||
.databi-shell--report .game-switch {
|
||||
border-color: #d8e5f0;
|
||||
background: #eef4fa;
|
||||
}
|
||||
|
||||
.databi-shell--report .view-mode-switch button,
|
||||
.databi-shell--report .screen-switch button,
|
||||
.databi-shell--report .game-switch button {
|
||||
color: #5f6f86;
|
||||
}
|
||||
|
||||
.databi-shell--report .view-mode-switch button:hover,
|
||||
.databi-shell--report .view-mode-switch button.is-active,
|
||||
.databi-shell--report .screen-switch button:hover,
|
||||
.databi-shell--report .screen-switch button.is-active,
|
||||
.databi-shell--report .game-switch button:hover,
|
||||
.databi-shell--report .game-switch button.is-active {
|
||||
background: #ffffff;
|
||||
color: #126fb2;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-trigger,
|
||||
.databi-shell--report .date-range-trigger {
|
||||
border-color: #d8e5f0;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-trigger:hover,
|
||||
.databi-shell--report .filter-trigger[aria-expanded="true"],
|
||||
.databi-shell--report .date-range-trigger:hover,
|
||||
.databi-shell--report .date-range-trigger[aria-expanded="true"] {
|
||||
border-color: #1688d9;
|
||||
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-label,
|
||||
.databi-shell--report .date-range-label {
|
||||
background: #ffffff;
|
||||
color: #718198;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-trigger:hover .filter-label,
|
||||
.databi-shell--report .filter-trigger[aria-expanded="true"] .filter-label,
|
||||
.databi-shell--report .date-range-trigger:hover .date-range-label,
|
||||
.databi-shell--report .date-range-trigger[aria-expanded="true"] .date-range-label,
|
||||
.databi-shell--report .control-glyph,
|
||||
.databi-shell--report .filter-label .filter-icon {
|
||||
color: #1688d9;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-value,
|
||||
.databi-shell--report .date-range-values b {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-icon,
|
||||
.databi-shell--report .filter-chevron,
|
||||
.databi-shell--report .date-range-chevron,
|
||||
.databi-shell--report .date-range-label svg {
|
||||
color: #718198;
|
||||
}
|
||||
|
||||
.databi-shell--report .date-range-values i {
|
||||
color: #8c9aab;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-popover,
|
||||
.databi-shell--report .date-time-popover {
|
||||
border-color: #d8e5f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-popover button,
|
||||
.databi-shell--report .date-time-shortcuts button,
|
||||
.databi-shell--report .date-time-fields button,
|
||||
.databi-shell--report .calendar-toolbar button,
|
||||
.databi-shell--report .calendar-day,
|
||||
.databi-shell--report .time-column button,
|
||||
.databi-shell--report .date-time-footer button {
|
||||
color: #34445a;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-popover button:hover,
|
||||
.databi-shell--report .filter-popover button.is-active,
|
||||
.databi-shell--report .date-time-shortcuts button:hover,
|
||||
.databi-shell--report .date-time-shortcuts button.is-active,
|
||||
.databi-shell--report .calendar-day:hover,
|
||||
.databi-shell--report .time-column button:hover,
|
||||
.databi-shell--report .time-column button.is-active {
|
||||
background: #eef7ff;
|
||||
color: #126fb2;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-empty,
|
||||
.databi-shell--report .filter-search span,
|
||||
.databi-shell--report .date-time-fields span,
|
||||
.databi-shell--report .date-time-footer span,
|
||||
.databi-shell--report .time-column > span,
|
||||
.databi-shell--report .calendar-weekday {
|
||||
color: #718198;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-search input,
|
||||
.databi-shell--report .date-time-fields button {
|
||||
border-color: #d8e5f0;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .filter-search input:focus,
|
||||
.databi-shell--report .date-time-fields button.is-active {
|
||||
border-color: #1688d9;
|
||||
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
|
||||
}
|
||||
|
||||
.databi-shell--report .calendar-toolbar,
|
||||
.databi-shell--report .calendar-pane,
|
||||
.databi-shell--report .date-time-fields,
|
||||
.databi-shell--report .date-time-footer,
|
||||
.databi-shell--report .time-column,
|
||||
.databi-shell--report .time-column > span {
|
||||
border-color: #e4ebf3;
|
||||
}
|
||||
|
||||
.databi-shell--report .calendar-toolbar strong,
|
||||
.databi-shell--report .date-time-fields b {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .calendar-day.is-muted {
|
||||
color: #b5c0cd;
|
||||
}
|
||||
|
||||
.databi-shell--report .calendar-day.is-start,
|
||||
.databi-shell--report .calendar-day.is-end,
|
||||
.databi-shell--report .calendar-day.is-active {
|
||||
background: #1688d9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.databi-shell--report .calendar-day.is-between {
|
||||
background: #e9f5ff;
|
||||
}
|
||||
|
||||
.databi-shell--report .date-time-footer button {
|
||||
border-color: #1688d9;
|
||||
background: #1688d9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.databi-report {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.report-metric-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 142px;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #dfe8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.report-metric-card--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-metric-card--clickable:hover,
|
||||
.report-metric-card--clickable:focus-visible {
|
||||
border-color: #1688d9;
|
||||
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.08), 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.report-metric-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-metric-head > span:first-child {
|
||||
overflow: hidden;
|
||||
color: #53647b;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-metric-card > strong {
|
||||
overflow: hidden;
|
||||
color: #172033;
|
||||
font-size: 28px;
|
||||
font-weight: 780;
|
||||
line-height: 1.14;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-metric-primary-label {
|
||||
color: #7b8a9d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.report-metric-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.report-metric-detail {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.report-metric-detail small {
|
||||
overflow: hidden;
|
||||
color: #8a98aa;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-metric-detail b {
|
||||
overflow: hidden;
|
||||
color: #243044;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-workbench {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.report-tabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
padding: 10px 12px 0;
|
||||
border-bottom: 1px solid #e4ebf3;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.report-tabs button {
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: #65748a;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-tabs button:hover,
|
||||
.report-tabs button.is-active {
|
||||
border-bottom-color: #1688d9;
|
||||
color: #126fb2;
|
||||
}
|
||||
|
||||
.report-tab-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-panel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.report-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-panel-head h2 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 18px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.report-panel-action {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #c7d7e7;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #126fb2;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-panel-action:hover {
|
||||
border-color: #1688d9;
|
||||
background: #eef7ff;
|
||||
}
|
||||
|
||||
.report-panel-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.report-metric-table-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-table-wrap {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e4ebf3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
min-width: 860px;
|
||||
border-collapse: collapse;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.report-table--wide {
|
||||
min-width: 2860px;
|
||||
}
|
||||
|
||||
.report-table th,
|
||||
.report-table td {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
color: #34445a;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.report-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f8fafc;
|
||||
color: #65748a;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-table th:first-child,
|
||||
.report-table td:first-child {
|
||||
width: 48px;
|
||||
color: #8a98aa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-table td.is-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover td {
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.report-row-total td {
|
||||
background: #eef7ff;
|
||||
color: #172033;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.report-row-date-total td {
|
||||
background: #f7fafc;
|
||||
color: #243044;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-row-total:hover td,
|
||||
.report-row-date-total:hover td {
|
||||
background: #e9f5ff;
|
||||
}
|
||||
|
||||
.report-sort-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.report-sort-button--left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.report-sort-button:hover,
|
||||
.report-sort-button.is-active {
|
||||
color: #126fb2;
|
||||
}
|
||||
|
||||
.report-sort-button i {
|
||||
color: #9aa7b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.report-country-cell {
|
||||
display: inline-flex;
|
||||
max-width: 220px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-country-cell i {
|
||||
flex: 0 0 auto;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.report-country-cell span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.report-date-cell {
|
||||
color: #53647b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-pair-cell {
|
||||
display: inline-flex;
|
||||
min-width: 118px;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-pair-cell b {
|
||||
color: #243044;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-pair-cell small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.report-trend-up,
|
||||
.report-delta {
|
||||
color: #0f9b72;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-trend-down,
|
||||
.report-delta--down {
|
||||
color: #d97706;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.report-delta--empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.report-table-skeleton-row td,
|
||||
.report-table-empty-row td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-table-empty-row td {
|
||||
height: 96px;
|
||||
color: #8a98aa;
|
||||
}
|
||||
|
||||
.report-empty {
|
||||
display: grid;
|
||||
min-height: 240px;
|
||||
place-items: center;
|
||||
border: 1px dashed #d8e5f0;
|
||||
border-radius: 8px;
|
||||
color: #8a98aa;
|
||||
}
|
||||
|
||||
.report-revenue-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border: 1px solid #e4ebf3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.report-chart-skeleton {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e4ebf3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.report-skeleton-title {
|
||||
width: 112px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.report-skeleton-value {
|
||||
width: 160px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.report-skeleton-line {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.report-skeleton-line--short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
.report-skeleton-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.databi-shell--report .databi-panel,
|
||||
.databi-shell--report .mini-stat,
|
||||
.databi-shell--report .metric-card {
|
||||
border-color: #dfe8f2;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.databi-shell--report .panel-title,
|
||||
.databi-shell--report .self-game-title strong,
|
||||
.databi-shell--report .metric-content strong,
|
||||
.databi-shell--report .risk-user-copy strong,
|
||||
.databi-shell--report .definition-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.databi-shell--report .panel-info {
|
||||
border-color: #c7d7e7;
|
||||
color: #6b7a90;
|
||||
}
|
||||
|
||||
.databi-shell--report .metric-label-row,
|
||||
.databi-shell--report .mini-stat > span,
|
||||
.databi-shell--report .section-label,
|
||||
.databi-shell--report .rank-title,
|
||||
.databi-shell--report .self-game-title span,
|
||||
.databi-shell--report .definition-row span,
|
||||
.databi-shell--report .risk-user-copy small {
|
||||
color: #65748a;
|
||||
}
|
||||
|
||||
.databi-shell--report .metric-title-icon,
|
||||
.databi-shell--report .metric-unit-icon {
|
||||
color: #1688d9;
|
||||
}
|
||||
|
||||
.databi-shell--report .metric-delta {
|
||||
border-top-color: #e4ebf3;
|
||||
color: #7b8a9d;
|
||||
}
|
||||
|
||||
.databi-shell--report .self-game-table th,
|
||||
.databi-shell--report .self-game-table td {
|
||||
border-bottom-color: #edf2f7;
|
||||
color: #34445a;
|
||||
}
|
||||
|
||||
.databi-shell--report .self-game-table th {
|
||||
background: #f8fafc;
|
||||
color: #65748a;
|
||||
}
|
||||
|
||||
.databi-shell--report .definition-row {
|
||||
border-color: #e4ebf3;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.databi-shell--report .panel-empty {
|
||||
color: #8a98aa;
|
||||
}
|
||||
12
money/index.html
Normal file
12
money/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HYApp 财务系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="money-root"></div>
|
||||
<script type="module" src="/money/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
234
money/src/MoneyApp.jsx
Normal file
234
money/src/MoneyApp.jsx
Normal file
@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchMoneySession } from "./api.js";
|
||||
import { CountryChannelPanel } from "./components/CountryChannelPanel.jsx";
|
||||
import { MoneyHeader } from "./components/MoneyHeader.jsx";
|
||||
import { MoneyOverview } from "./components/MoneyOverview.jsx";
|
||||
import { MONEY_VIEWS, MoneySidebar } from "./components/MoneySidebar.jsx";
|
||||
import { OperatorPerformancePanel } from "./components/OperatorPerformancePanel.jsx";
|
||||
import { PaymentLinkPanel } from "./components/PaymentLinkPanel.jsx";
|
||||
import { PerformanceTable } from "./components/PerformanceTable.jsx";
|
||||
import {
|
||||
createPaymentLinkRecord,
|
||||
filterMoneyModules,
|
||||
initialPaymentLinks,
|
||||
moneyAppOptions,
|
||||
moneyCountries,
|
||||
moneyOperators,
|
||||
moneyScopeOptions,
|
||||
summarizeMoneyModules
|
||||
} from "./data.js";
|
||||
|
||||
export function MoneyApp() {
|
||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||
const [activeView, setActiveView] = useState("overview");
|
||||
const [mode, setMode] = useState("finance");
|
||||
const [activeOperatorId, setActiveOperatorId] = useState("sarah-ali");
|
||||
const [appCode, setAppCode] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [scope, setScope] = useState("");
|
||||
const [operatorKeyword, setOperatorKeyword] = useState("");
|
||||
const [links, setLinks] = useState(() => initialPaymentLinks);
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
fetchMoneySession()
|
||||
.then((session) => {
|
||||
if (mounted) {
|
||||
setSessionState({ error: "", loading: false, session });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
setSessionState({ error: err.message || "会话校验失败", loading: false, session: null });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredModules = useMemo(
|
||||
() =>
|
||||
filterMoneyModules({
|
||||
activeOperatorId,
|
||||
appCode,
|
||||
countryCode,
|
||||
mode,
|
||||
operatorKeyword: mode === "finance" ? operatorKeyword : "",
|
||||
scope
|
||||
}),
|
||||
[activeOperatorId, appCode, countryCode, mode, operatorKeyword, scope]
|
||||
);
|
||||
const summary = useMemo(() => summarizeMoneyModules(filteredModules), [filteredModules]);
|
||||
const visibleModuleIds = useMemo(() => new Set(filteredModules.map((module) => module.id)), [filteredModules]);
|
||||
const visibleLinks = useMemo(() => links.filter((link) => visibleModuleIds.has(link.moduleId)), [links, visibleModuleIds]);
|
||||
const activeOperator = moneyOperators.find((operator) => operator.id === activeOperatorId) || moneyOperators[0];
|
||||
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
|
||||
|
||||
const resetFilters = () => {
|
||||
setAppCode("");
|
||||
setCountryCode("");
|
||||
setScope("");
|
||||
setOperatorKeyword("");
|
||||
};
|
||||
|
||||
const showToast = (message) => {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 1600);
|
||||
};
|
||||
|
||||
const createLink = (payload) => {
|
||||
const nextLink = createPaymentLinkRecord({
|
||||
...payload,
|
||||
operatorName: mode === "operator" ? activeOperator.label : undefined
|
||||
});
|
||||
setLinks((current) => [nextLink, ...current]);
|
||||
showToast("支付链接已创建");
|
||||
};
|
||||
|
||||
const copyLink = async (value) => {
|
||||
await copyText(value);
|
||||
showToast("链接已复制");
|
||||
};
|
||||
|
||||
const exportReport = () => {
|
||||
downloadTextFile(createCsv(filteredModules), `hyapp-money-${Date.now()}.csv`);
|
||||
showToast("财务报表已导出");
|
||||
};
|
||||
|
||||
if (sessionState.loading) {
|
||||
return <StateScreen title="正在进入财务系统" />;
|
||||
}
|
||||
|
||||
if (sessionState.error) {
|
||||
return <StateScreen title={sessionState.error} />;
|
||||
}
|
||||
|
||||
if (!sessionState.session?.canViewMoney) {
|
||||
return <StateScreen title="无 money:view" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="money-shell">
|
||||
<MoneySidebar
|
||||
activeOperator={activeOperator}
|
||||
activeView={activeView}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onViewChange={setActiveView}
|
||||
session={sessionState.session}
|
||||
summary={summary}
|
||||
/>
|
||||
<section className="money-main">
|
||||
<MoneyHeader
|
||||
activeOperatorId={activeOperatorId}
|
||||
appCode={appCode}
|
||||
appOptions={moneyAppOptions}
|
||||
countryCode={countryCode}
|
||||
countryOptions={[["", "全部国家"], ...moneyCountries.map((country) => [country.code, country.label])]}
|
||||
mode={mode}
|
||||
onActiveOperatorChange={setActiveOperatorId}
|
||||
onAppChange={setAppCode}
|
||||
onCountryChange={setCountryCode}
|
||||
onExport={exportReport}
|
||||
onOperatorKeywordChange={setOperatorKeyword}
|
||||
onReset={resetFilters}
|
||||
onScopeChange={setScope}
|
||||
operatorKeyword={operatorKeyword}
|
||||
operators={moneyOperators}
|
||||
scope={scope}
|
||||
scopeOptions={moneyScopeOptions}
|
||||
view={view}
|
||||
/>
|
||||
<div className="money-content">{renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks })}</div>
|
||||
</section>
|
||||
{toast ? <div className="money-toast">{toast}</div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent(activeView, { copyLink, createLink, filteredModules, summary, visibleLinks }) {
|
||||
if (activeView === "modules") {
|
||||
return <PerformanceTable meta={`${filteredModules.length} modules`} modules={filteredModules} summary={summary} title="模块绩效" />;
|
||||
}
|
||||
|
||||
if (activeView === "operators") {
|
||||
return <OperatorPerformancePanel modules={filteredModules} />;
|
||||
}
|
||||
|
||||
if (activeView === "payments") {
|
||||
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreateLink={createLink} />;
|
||||
}
|
||||
|
||||
if (activeView === "countries") {
|
||||
return <CountryChannelPanel links={visibleLinks} modules={filteredModules} />;
|
||||
}
|
||||
|
||||
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
|
||||
}
|
||||
|
||||
function StateScreen({ detail = "", title }) {
|
||||
return (
|
||||
<main className="money-shell money-shell--state">
|
||||
<section className="money-state">
|
||||
<h1>{title}</h1>
|
||||
{detail ? <p>{detail}</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function createCsv(modules) {
|
||||
const header = ["模块", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
|
||||
const rows = modules.map((module) => [
|
||||
module.moduleName,
|
||||
module.operatorName,
|
||||
module.countryName,
|
||||
module.rechargeUsd,
|
||||
module.revenueUsd,
|
||||
module.salaryUsd,
|
||||
module.newUsers,
|
||||
module.dau,
|
||||
module.dayTargetUsd,
|
||||
module.monthTargetUsd
|
||||
]);
|
||||
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? "");
|
||||
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function downloadTextFile(content, filename) {
|
||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
} catch {
|
||||
// 本地 HTTP 调试时浏览器可能拒绝 Clipboard API,退回同步复制以保证财务能拿到刚生成的收款链接。
|
||||
}
|
||||
}
|
||||
const input = document.createElement("textarea");
|
||||
input.value = value;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
51
money/src/api.js
Normal file
51
money/src/api.js
Normal file
@ -0,0 +1,51 @@
|
||||
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
|
||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||
const MONEY_PERMISSION = "money:view";
|
||||
|
||||
export async function fetchMoneySession() {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/me`, {
|
||||
credentials: "include",
|
||||
headers: requestHeaders()
|
||||
});
|
||||
const payload = await readJSON(response);
|
||||
if (response.status === 401 || Number(payload?.code) === 40100) {
|
||||
redirectToLogin();
|
||||
throw new Error("登录已失效");
|
||||
}
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(payload.message || response.statusText || "会话校验失败");
|
||||
}
|
||||
const session = payload.data || {};
|
||||
return {
|
||||
...session,
|
||||
canViewMoney: Array.isArray(session.permissions) && session.permissions.includes(MONEY_PERMISSION)
|
||||
};
|
||||
}
|
||||
|
||||
function requestHeaders() {
|
||||
const headers = {};
|
||||
const token = window.localStorage.getItem(TOKEN_KEY);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readJSON(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { code: response.ok ? 0 : response.status, message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
43
money/src/charts/EChart.jsx
Normal file
43
money/src/charts/EChart.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import * as echarts from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
echarts.use([BarChart, GridComponent, LegendComponent, LineChart, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
export function EChart({ className = "", option }) {
|
||||
const elementRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const optionRef = useRef(option);
|
||||
|
||||
useEffect(() => {
|
||||
optionRef.current = option;
|
||||
chartRef.current?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chart = echarts.init(element, null, { renderer: "canvas" });
|
||||
chartRef.current = chart;
|
||||
chart.setOption(optionRef.current, true);
|
||||
|
||||
const resize = () => chart.resize();
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
|
||||
observer?.observe(element);
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
chart.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={["money-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
|
||||
}
|
||||
67
money/src/components/CountryChannelPanel.jsx
Normal file
67
money/src/components/CountryChannelPanel.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { availableProvidersForCountry, moneyCountries, summarizeMoneyModules } from "../data.js";
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function CountryChannelPanel({ links, modules }) {
|
||||
const rows = moneyCountries
|
||||
.map((country) => {
|
||||
const countryModules = modules.filter((module) => module.countryCode === country.code);
|
||||
const countryLinks = links.filter((link) => link.countryCode === country.code);
|
||||
const summary = summarizeMoneyModules(countryModules);
|
||||
return {
|
||||
...country,
|
||||
linkCount: countryLinks.length,
|
||||
modules: countryModules,
|
||||
paidLinkCount: countryLinks.filter((link) => link.status === "paid").length,
|
||||
providers: availableProvidersForCountry(country.code),
|
||||
summary
|
||||
};
|
||||
})
|
||||
.filter((country) => country.modules.length > 0 || country.linkCount > 0)
|
||||
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
|
||||
|
||||
return (
|
||||
<section className="money-country-grid">
|
||||
{rows.map((country) => (
|
||||
<article className="money-panel money-country-card" key={country.code}>
|
||||
<div className="money-country-head">
|
||||
<div>
|
||||
<span>{country.region}</span>
|
||||
<h2>{country.label}</h2>
|
||||
</div>
|
||||
<strong>{country.currency}</strong>
|
||||
</div>
|
||||
<div className="money-country-metrics">
|
||||
<Metric label="充值" value={formatUsd(country.summary.rechargeUsd)} tone="success" />
|
||||
<Metric label="链接转化" value={formatPercent(ratio(country.paidLinkCount, country.linkCount))} tone="primary" />
|
||||
<Metric label="模块" value={formatNumber(country.modules.length)} tone="neutral" />
|
||||
</div>
|
||||
<div className="money-provider-list">
|
||||
{country.providers.map((provider) => (
|
||||
<span key={provider.code}>{provider.label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="money-country-foot">
|
||||
<span>{formatNumber(country.summary.newUsers)} 新增</span>
|
||||
<span>{formatNumber(country.summary.dau)} DAU</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, tone, value }) {
|
||||
return (
|
||||
<div className={`money-country-metric money-country-metric--${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ratio(value, total) {
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(value) / Number(total)) * 100;
|
||||
}
|
||||
75
money/src/components/MoneyHeader.jsx
Normal file
75
money/src/components/MoneyHeader.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||
export function MoneyHeader({
|
||||
activeOperatorId,
|
||||
appCode,
|
||||
appOptions,
|
||||
countryCode,
|
||||
countryOptions,
|
||||
mode,
|
||||
onActiveOperatorChange,
|
||||
onAppChange,
|
||||
onCountryChange,
|
||||
onExport,
|
||||
onOperatorKeywordChange,
|
||||
onReset,
|
||||
onScopeChange,
|
||||
operatorKeyword,
|
||||
operators,
|
||||
scope,
|
||||
scopeOptions,
|
||||
view
|
||||
}) {
|
||||
return (
|
||||
<header className="money-header">
|
||||
<div className="money-header-title">
|
||||
<h1>{view.title}</h1>
|
||||
</div>
|
||||
<div className="money-header-tools">
|
||||
<div className="money-filter-group">
|
||||
<FilterSelect label="App" options={appOptions} value={appCode} onChange={onAppChange} />
|
||||
<FilterSelect label="国家" options={countryOptions} value={countryCode} onChange={onCountryChange} />
|
||||
<FilterSelect label="模块" options={scopeOptions} value={scope} onChange={onScopeChange} />
|
||||
{mode === "finance" ? (
|
||||
<label className="money-filter money-filter--search">
|
||||
<span>运营人员</span>
|
||||
<input placeholder="姓名 / 部门" value={operatorKeyword} onChange={(event) => onOperatorKeywordChange(event.target.value)} />
|
||||
</label>
|
||||
) : (
|
||||
<FilterSelect
|
||||
label="当前运营"
|
||||
options={operators.map((operator) => [operator.id, operator.label])}
|
||||
value={activeOperatorId}
|
||||
onChange={onActiveOperatorChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="money-actions">
|
||||
<button className="money-button" onClick={onReset} type="button">
|
||||
<RestartAltOutlined fontSize="small" />
|
||||
重置
|
||||
</button>
|
||||
<button className="money-button money-button--primary" onClick={onExport} type="button">
|
||||
<FileDownloadOutlined fontSize="small" />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({ label, onChange, options, value }) {
|
||||
return (
|
||||
<label className="money-filter">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue || "all"} value={optionValue}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
23
money/src/components/MoneyKpis.jsx
Normal file
23
money/src/components/MoneyKpis.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function MoneyKpis({ summary }) {
|
||||
const cards = [
|
||||
["总充值", formatUsd(summary.rechargeUsd), `ARPPU ${formatUsd(summary.arppuUsd)}`, "success"],
|
||||
["App 流水", formatUsd(summary.revenueUsd), `毛利 ${formatUsd(summary.grossProfitUsd)}`, "primary"],
|
||||
["存量工资", formatUsd(summary.salaryUsd), `日目标 ${formatPercent(summary.dayProgress)}`, "warning"],
|
||||
["新增 / 日活", formatNumber(summary.newUsers), `DAU ${formatNumber(summary.dau)}`, "info"],
|
||||
["支付链接转化", formatPercent(summary.linkConversion), `${formatNumber(summary.paidLinkCount)} / ${formatNumber(summary.totalLinkCount)} 已支付`, "neutral"]
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="money-kpis">
|
||||
{cards.map(([label, value, sub, tone]) => (
|
||||
<article className={`money-kpi money-kpi--${tone}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{sub}</small>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
money/src/components/MoneyOverview.jsx
Normal file
85
money/src/components/MoneyOverview.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { MoneyKpis } from "./MoneyKpis.jsx";
|
||||
import { MoneyTrendPanel } from "./MoneyTrendPanel.jsx";
|
||||
import { formatDateTime, formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
import { paymentStatusLabel } from "../data.js";
|
||||
|
||||
export function MoneyOverview({ links, modules, summary }) {
|
||||
const topModules = [...modules].sort((a, b) => b.rechargeUsd - a.rechargeUsd).slice(0, 4);
|
||||
const recentLinks = links.slice(0, 4);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MoneyKpis summary={summary} />
|
||||
<MoneyTrendPanel summary={summary} />
|
||||
<section className="money-workbench-grid">
|
||||
<article className="money-panel money-focus-panel">
|
||||
<div className="money-panel-head">
|
||||
<div>
|
||||
<h2>高价值模块</h2>
|
||||
<span>Top 4</span>
|
||||
</div>
|
||||
<strong>{formatUsd(summary.grossProfitUsd)}</strong>
|
||||
</div>
|
||||
<div className="money-focus-list">
|
||||
{topModules.map((module, index) => (
|
||||
<div className="money-focus-row" key={module.id}>
|
||||
<b>{String(index + 1).padStart(2, "0")}</b>
|
||||
<span>
|
||||
<strong>{module.moduleName}</strong>
|
||||
<small>{module.operatorName} · {module.countryName}</small>
|
||||
</span>
|
||||
<em>{formatUsd(module.rechargeUsd)}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="money-panel money-focus-panel">
|
||||
<div className="money-panel-head">
|
||||
<div>
|
||||
<h2>支付链接动态</h2>
|
||||
<span>Last 4</span>
|
||||
</div>
|
||||
<strong>{formatPercent(summary.linkConversion)}</strong>
|
||||
</div>
|
||||
<div className="money-focus-list">
|
||||
{recentLinks.map((link) => (
|
||||
<div className="money-focus-row" key={link.id}>
|
||||
<b>{link.countryCode}</b>
|
||||
<span>
|
||||
<strong>{link.moduleName}</strong>
|
||||
<small>{formatDateTime(link.createdAtMs)} · {paymentStatusLabel(link.status)}</small>
|
||||
</span>
|
||||
<em>{formatUsd(link.amountUsd)}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="money-panel money-focus-panel money-focus-panel--wide">
|
||||
<div className="money-panel-head">
|
||||
<div>
|
||||
<h2>经营信号</h2>
|
||||
<span>D0</span>
|
||||
</div>
|
||||
<strong>{formatNumber(summary.newUsers)}</strong>
|
||||
</div>
|
||||
<div className="money-signal-grid">
|
||||
<Signal label="D目标" value={formatPercent(summary.dayProgress)} tone={summary.dayProgress >= 100 ? "success" : "warning"} />
|
||||
<Signal label="新增质量" value={`次留 ${formatPercent(summary.retention1)}`} tone="info" />
|
||||
<Signal label="活跃规模" value={`DAU ${formatNumber(summary.dau)}`} tone="neutral" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Signal({ label, tone, value }) {
|
||||
return (
|
||||
<div className={`money-signal money-signal--${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
money/src/components/MoneySidebar.jsx
Normal file
101
money/src/components/MoneySidebar.jsx
Normal file
@ -0,0 +1,101 @@
|
||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
||||
import DashboardCustomizeOutlined from "@mui/icons-material/DashboardCustomizeOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import TableRowsOutlined from "@mui/icons-material/TableRowsOutlined";
|
||||
import { formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export const MONEY_VIEWS = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "财务总览",
|
||||
title: "财务总览",
|
||||
Icon: DashboardCustomizeOutlined
|
||||
},
|
||||
{
|
||||
id: "modules",
|
||||
label: "模块绩效",
|
||||
title: "模块绩效",
|
||||
Icon: TableRowsOutlined
|
||||
},
|
||||
{
|
||||
id: "operators",
|
||||
label: "运营绩效",
|
||||
title: "运营绩效",
|
||||
Icon: GroupsOutlined
|
||||
},
|
||||
{
|
||||
id: "payments",
|
||||
label: "支付链接",
|
||||
title: "支付链接",
|
||||
Icon: AddLinkOutlined
|
||||
},
|
||||
{
|
||||
id: "countries",
|
||||
label: "国家渠道",
|
||||
title: "国家渠道",
|
||||
Icon: PublicOutlined
|
||||
}
|
||||
];
|
||||
|
||||
export function MoneySidebar({ activeOperator, activeView, mode, onModeChange, onViewChange, session, summary }) {
|
||||
return (
|
||||
<aside className="money-sidebar">
|
||||
<div className="money-sidebar-brand">
|
||||
<div className="money-sidebar-mark">
|
||||
<AccountBalanceWalletOutlined fontSize="small" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Money OS</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="money-sidebar-nav" aria-label="财务系统菜单">
|
||||
{MONEY_VIEWS.map(({ Icon, id, label }) => (
|
||||
<button
|
||||
aria-current={activeView === id ? "page" : undefined}
|
||||
className={activeView === id ? "is-active" : ""}
|
||||
key={id}
|
||||
onClick={() => onViewChange(id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="money-sidebar-card">
|
||||
<span>MODE</span>
|
||||
<div className="money-mode-switch" role="tablist" aria-label="财务视角">
|
||||
<button className={mode === "finance" ? "is-active" : ""} onClick={() => onModeChange("finance")} role="tab" type="button">
|
||||
FIN
|
||||
</button>
|
||||
<button className={mode === "operator" ? "is-active" : ""} onClick={() => onModeChange("operator")} role="tab" type="button">
|
||||
OPS
|
||||
</button>
|
||||
</div>
|
||||
<strong>{mode === "operator" ? activeOperator.label : "ALL OPS"}</strong>
|
||||
<small>{mode === "operator" ? activeOperator.department : "FINANCE"}</small>
|
||||
</section>
|
||||
|
||||
<section className="money-sidebar-metric">
|
||||
<PaymentsOutlined fontSize="small" />
|
||||
<div>
|
||||
<span>D0 充值</span>
|
||||
<strong>{formatUsd(summary.rechargeUsd)}</strong>
|
||||
<small>CVR {formatPercent(summary.linkConversion)}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="money-sidebar-user">
|
||||
<span>{session?.user?.username || "admin"}</span>
|
||||
<small>money:view</small>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
101
money/src/components/MoneyTrendPanel.jsx
Normal file
101
money/src/components/MoneyTrendPanel.jsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { EChart } from "../charts/EChart.jsx";
|
||||
import { trendLabels } from "../data.js";
|
||||
import { formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function MoneyTrendPanel({ summary }) {
|
||||
return (
|
||||
<section className="money-analysis">
|
||||
<article className="money-panel money-panel--chart">
|
||||
<PanelHead label="充值与流水趋势" meta="近 7 日" value={formatUsd(summary.revenueUsd)} />
|
||||
<EChart className="money-trend-chart" option={createTrendOption(summary)} />
|
||||
</article>
|
||||
<article className="money-panel money-panel--targets">
|
||||
<PanelHead label="目标达成" meta="当日 / 当月" value={formatPercent(summary.monthProgress)} />
|
||||
<ProgressRow label="当日充值" percent={summary.dayProgress} value={`${formatUsd(summary.rechargeUsd)} / ${formatUsd(summary.dayTargetUsd)}`} />
|
||||
<ProgressRow label="当月充值" percent={summary.monthProgress} value={`${formatUsd(summary.monthActualUsd)} / ${formatUsd(summary.monthTargetUsd)}`} />
|
||||
<div className="money-retention">
|
||||
<span>次留 {formatPercent(summary.retention1)}</span>
|
||||
<span>7日 {formatPercent(summary.retention7)}</span>
|
||||
<span>30日 {formatPercent(summary.retention30)}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelHead({ label, meta, value }) {
|
||||
return (
|
||||
<div className="money-panel-head">
|
||||
<div>
|
||||
<h2>{label}</h2>
|
||||
<span>{meta}</span>
|
||||
</div>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressRow({ label, percent, value }) {
|
||||
return (
|
||||
<div className="money-progress">
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<strong>{formatPercent(percent)}</strong>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
|
||||
</i>
|
||||
<small>{value}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createTrendOption(summary) {
|
||||
return {
|
||||
animationDuration: 520,
|
||||
color: ["#2563eb", "#16a34a"],
|
||||
grid: { bottom: 30, containLabel: true, left: 6, right: 16, top: 24 },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
icon: "roundRect",
|
||||
itemHeight: 8,
|
||||
itemWidth: 16,
|
||||
textStyle: { color: "#64748b" }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e0ec",
|
||||
trigger: "axis",
|
||||
textStyle: { color: "#0f172a" },
|
||||
valueFormatter: (value) => formatUsd(value)
|
||||
},
|
||||
xAxis: {
|
||||
axisLine: { lineStyle: { color: "rgba(100, 116, 139, 0.18)" } },
|
||||
axisTick: { show: false },
|
||||
data: trendLabels,
|
||||
type: "category"
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: { formatter: (value) => `${Math.round(value / 1000)}k` },
|
||||
splitLine: { lineStyle: { color: "rgba(100, 116, 139, 0.16)" } },
|
||||
type: "value"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: { opacity: 0.12 },
|
||||
data: summary.rechargeTrend,
|
||||
name: "充值",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
type: "line"
|
||||
},
|
||||
{
|
||||
barMaxWidth: 18,
|
||||
data: summary.revenueTrend,
|
||||
name: "流水",
|
||||
type: "bar"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
92
money/src/components/OperatorPerformancePanel.jsx
Normal file
92
money/src/components/OperatorPerformancePanel.jsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { moneyOperators, summarizeMoneyModules } from "../data.js";
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function OperatorPerformancePanel({ modules }) {
|
||||
const rows = moneyOperators
|
||||
.map((operator) => {
|
||||
const ownedModules = modules.filter((module) => module.operatorId === operator.id);
|
||||
return {
|
||||
...operator,
|
||||
modules: ownedModules,
|
||||
summary: summarizeMoneyModules(ownedModules)
|
||||
};
|
||||
})
|
||||
.filter((operator) => operator.modules.length > 0)
|
||||
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
|
||||
|
||||
return (
|
||||
<section className="money-operator-board">
|
||||
<div className="money-operator-cards">
|
||||
{rows.map((operator, index) => (
|
||||
<article className="money-panel money-operator-card" key={operator.id}>
|
||||
<div className="money-rank">{String(index + 1).padStart(2, "0")}</div>
|
||||
<span>{operator.department}</span>
|
||||
<h2>{operator.label}</h2>
|
||||
<strong>{formatUsd(operator.summary.rechargeUsd)}</strong>
|
||||
<div className="money-operator-meta">
|
||||
<small>{operator.modules.length} modules</small>
|
||||
<small>M {formatPercent(operator.summary.monthProgress)}</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<article className="money-panel money-table-panel">
|
||||
<div className="money-table-head">
|
||||
<div>
|
||||
<h2>运营绩效排行</h2>
|
||||
<span>{rows.length} operators</span>
|
||||
</div>
|
||||
<strong>{rows.length} 人</strong>
|
||||
</div>
|
||||
<div className="money-table-scroll">
|
||||
<table className="money-table money-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>运营</th>
|
||||
<th>负责模块</th>
|
||||
<th>充值</th>
|
||||
<th>流水</th>
|
||||
<th>存量工资</th>
|
||||
<th>毛利</th>
|
||||
<th>新增 / 日活</th>
|
||||
<th>日目标</th>
|
||||
<th>月目标</th>
|
||||
<th>链接转化</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((operator) => (
|
||||
<tr key={operator.id}>
|
||||
<td>
|
||||
<Stack primary={operator.label} secondary={operator.department} />
|
||||
</td>
|
||||
<td>{operator.modules.length}</td>
|
||||
<td className="money-num money-num--success">{formatUsd(operator.summary.rechargeUsd)}</td>
|
||||
<td className="money-num">{formatUsd(operator.summary.revenueUsd)}</td>
|
||||
<td className="money-num money-num--warning">{formatUsd(operator.summary.salaryUsd)}</td>
|
||||
<td className="money-num money-num--primary">{formatUsd(operator.summary.grossProfitUsd)}</td>
|
||||
<td>
|
||||
<Stack primary={formatNumber(operator.summary.newUsers)} secondary={`DAU ${formatNumber(operator.summary.dau)}`} />
|
||||
</td>
|
||||
<td>{formatPercent(operator.summary.dayProgress)}</td>
|
||||
<td>{formatPercent(operator.summary.monthProgress)}</td>
|
||||
<td>{formatPercent(operator.summary.linkConversion)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<span className="money-stack">
|
||||
<strong>{primary || "-"}</strong>
|
||||
<small>{secondary || "-"}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
107
money/src/components/PaymentLinkPanel.jsx
Normal file
107
money/src/components/PaymentLinkPanel.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
|
||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import { availableCountriesForModule, availableProvidersForCountry, paymentStatusLabel } from "../data.js";
|
||||
import { formatDateTime, formatUsd } from "../format.js";
|
||||
import { PanelHead } from "./MoneyTrendPanel.jsx";
|
||||
|
||||
export function PaymentLinkPanel({ links, modules, onCopy, onCreateLink }) {
|
||||
const [moduleId, setModuleId] = useState(() => modules[0]?.id || "");
|
||||
const countries = useMemo(() => availableCountriesForModule(moduleId), [moduleId]);
|
||||
const [countryCode, setCountryCode] = useState(() => countries[0]?.code || "");
|
||||
const providers = useMemo(() => availableProvidersForCountry(countryCode), [countryCode]);
|
||||
const [providerCode, setProviderCode] = useState(() => providers[0]?.code || "");
|
||||
const [amountUsd, setAmountUsd] = useState("99");
|
||||
const canSubmit = Boolean(moduleId && countryCode && providerCode && Number(amountUsd) > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modules.some((module) => module.id === moduleId)) {
|
||||
setModuleId(modules[0]?.id || "");
|
||||
}
|
||||
}, [moduleId, modules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!countries.some((country) => country.code === countryCode)) {
|
||||
setCountryCode(countries[0]?.code || "");
|
||||
}
|
||||
}, [countries, countryCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providers.some((provider) => provider.code === providerCode)) {
|
||||
setProviderCode(providers[0]?.code || "");
|
||||
}
|
||||
}, [providerCode, providers]);
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
onCreateLink({ amountUsd, countryCode, moduleId, providerCode });
|
||||
setAmountUsd("99");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="money-panel money-link-panel">
|
||||
<PanelHead label="国家支付链接" meta={`${modules.length} modules`} value={String(links.length)} />
|
||||
<form className="money-link-form" onSubmit={submit}>
|
||||
<Select label="负责模块" value={moduleId} onChange={setModuleId} options={modules.map((module) => [module.id, module.moduleName])} />
|
||||
<Select label="国家" value={countryCode} onChange={setCountryCode} options={countries.map((country) => [country.code, `${country.label} / ${country.currency}`])} />
|
||||
<Select label="渠道" value={providerCode} onChange={setProviderCode} options={providers.map((provider) => [provider.code, provider.label])} />
|
||||
<label className="money-field">
|
||||
<span>金额 USD</span>
|
||||
<input min="1" step="0.01" type="number" value={amountUsd} onChange={(event) => setAmountUsd(event.target.value)} />
|
||||
</label>
|
||||
<button className="money-button money-button--primary" disabled={!canSubmit} type="submit">
|
||||
<AddLinkOutlined fontSize="small" />
|
||||
创建链接
|
||||
</button>
|
||||
</form>
|
||||
<div className="money-link-list">
|
||||
{links.slice(0, 5).map((link) => (
|
||||
<article className="money-link-row" key={link.id}>
|
||||
<div>
|
||||
<strong>{link.moduleName}</strong>
|
||||
<span>{`${link.countryName} / ${link.providerName} / ${formatUsd(link.amountUsd)}`}</span>
|
||||
<code title={link.payUrl}>{link.payUrl}</code>
|
||||
</div>
|
||||
<aside>
|
||||
<span className={`money-status ${link.status === "paid" ? "money-status--success" : ""}`}>{paymentStatusLabel(link.status)}</span>
|
||||
<small>{formatDateTime(link.createdAtMs)}</small>
|
||||
</aside>
|
||||
<nav>
|
||||
<button className="money-icon-button" onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</button>
|
||||
<button className="money-icon-button" onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
|
||||
<OpenInNewOutlined fontSize="small" />
|
||||
</button>
|
||||
</nav>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, onChange, options, value }) {
|
||||
return (
|
||||
<label className="money-field">
|
||||
<span>{label}</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<option key={optionValue} value={optionValue}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function openLink(value) {
|
||||
if (value) {
|
||||
window.open(value, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
136
money/src/components/PerformanceTable.jsx
Normal file
136
money/src/components/PerformanceTable.jsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { formatNumber, formatPercent, formatUsd } from "../format.js";
|
||||
|
||||
export function PerformanceTable({ meta = "", modules, summary, title = "运营模块绩效" }) {
|
||||
const rows = [createTotalRow(summary), ...modules];
|
||||
return (
|
||||
<section className="money-panel money-table-panel">
|
||||
<div className="money-table-head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span>{meta || `${modules.length} modules`}</span>
|
||||
</div>
|
||||
<strong>{modules.length}</strong>
|
||||
</div>
|
||||
<div className="money-table-scroll">
|
||||
<table className="money-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>负责模块</th>
|
||||
<th>负责人</th>
|
||||
<th>模块类型</th>
|
||||
<th>国家 / 地区</th>
|
||||
<th>充值</th>
|
||||
<th>App 流水</th>
|
||||
<th>存量工资</th>
|
||||
<th>毛利</th>
|
||||
<th>新增 / 日活</th>
|
||||
<th>当日目标</th>
|
||||
<th>当月目标</th>
|
||||
<th>留存</th>
|
||||
<th>支付链接</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr className={row.isTotal ? "is-total" : ""} key={row.id}>
|
||||
<td>
|
||||
<Stack primary={row.moduleName} secondary={row.appName} />
|
||||
</td>
|
||||
<td>
|
||||
<Stack primary={row.operatorName} secondary={row.department} />
|
||||
</td>
|
||||
<td>
|
||||
<Scope scope={row.scope} />
|
||||
</td>
|
||||
<td>
|
||||
<Stack primary={row.countryName} secondary={row.region} />
|
||||
</td>
|
||||
<td className="money-num money-num--success">{formatUsd(row.rechargeUsd)}</td>
|
||||
<td className="money-num">{formatUsd(row.revenueUsd)}</td>
|
||||
<td className="money-num money-num--warning">{formatUsd(row.salaryUsd)}</td>
|
||||
<td className="money-num money-num--primary">{formatUsd(row.revenueUsd - row.salaryUsd)}</td>
|
||||
<td>
|
||||
<Stack primary={formatNumber(row.newUsers)} secondary={`DAU ${formatNumber(row.dau)}`} />
|
||||
</td>
|
||||
<td>
|
||||
<MiniProgress actual={row.rechargeUsd} percent={row.dayProgress} target={row.dayTargetUsd} />
|
||||
</td>
|
||||
<td>
|
||||
<MiniProgress actual={row.monthActualUsd} percent={row.monthProgress} target={row.monthTargetUsd} />
|
||||
</td>
|
||||
<td>
|
||||
<Stack primary={`次留 ${formatPercent(row.retention1)}`} secondary={`7日 ${formatPercent(row.retention7)} / 30日 ${formatPercent(row.retention30)}`} />
|
||||
</td>
|
||||
<td>
|
||||
<Stack primary={`${formatNumber(row.paidLinkCount)} / ${formatNumber(row.linkCount)}`} secondary={`金币 ${formatNumber(row.coinIssued)}`} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<span className="money-stack">
|
||||
<strong>{primary || "-"}</strong>
|
||||
<small>{secondary || "-"}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Scope({ scope }) {
|
||||
const label = scope === "app" ? "App" : scope === "region" ? "地区" : "汇总";
|
||||
return <span className={`money-scope money-scope--${scope}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function MiniProgress({ actual, percent, target }) {
|
||||
if (!target) {
|
||||
return <span className="money-empty-tag">无目标</span>;
|
||||
}
|
||||
return (
|
||||
<span className="money-mini-progress">
|
||||
<span>
|
||||
<b>{formatUsd(actual)}</b>
|
||||
<strong>{formatPercent(percent)}</strong>
|
||||
</span>
|
||||
<i>
|
||||
<em style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
|
||||
</i>
|
||||
<small>{formatUsd(target)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createTotalRow(summary) {
|
||||
return {
|
||||
appName: "全部 App",
|
||||
coinIssued: summary.coinIssued,
|
||||
countryName: "全部国家",
|
||||
dau: summary.dau,
|
||||
dayProgress: summary.dayProgress,
|
||||
dayTargetUsd: summary.dayTargetUsd,
|
||||
department: "财务汇总",
|
||||
id: "__total",
|
||||
isTotal: true,
|
||||
linkCount: summary.totalLinkCount,
|
||||
moduleName: "Total",
|
||||
monthActualUsd: summary.monthActualUsd,
|
||||
monthProgress: summary.monthProgress,
|
||||
monthTargetUsd: summary.monthTargetUsd,
|
||||
newUsers: summary.newUsers,
|
||||
operatorName: "全部负责人",
|
||||
paidLinkCount: summary.paidLinkCount,
|
||||
rechargeUsd: summary.rechargeUsd,
|
||||
region: "Global",
|
||||
retention1: summary.retention1,
|
||||
retention7: summary.retention7,
|
||||
retention30: summary.retention30,
|
||||
revenueUsd: summary.revenueUsd,
|
||||
salaryUsd: summary.salaryUsd,
|
||||
scope: "total"
|
||||
};
|
||||
}
|
||||
16
money/src/data.js
Normal file
16
money/src/data.js
Normal file
@ -0,0 +1,16 @@
|
||||
export {
|
||||
availableCountriesForModule,
|
||||
availableProvidersForCountry,
|
||||
createPaymentLinkRecord,
|
||||
filterMoneyModules,
|
||||
initialPaymentLinks,
|
||||
moneyAppOptions,
|
||||
moneyCountries,
|
||||
moneyModules,
|
||||
moneyOperators,
|
||||
moneyProviderOptions,
|
||||
moneyScopeOptions,
|
||||
paymentStatusLabel,
|
||||
summarizeMoneyModules,
|
||||
trendLabels
|
||||
} from "./model/data.js";
|
||||
1
money/src/format.js
Normal file
1
money/src/format.js
Normal file
@ -0,0 +1 @@
|
||||
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";
|
||||
10
money/src/main.jsx
Normal file
10
money/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MoneyApp } from "./MoneyApp.jsx";
|
||||
import "./styles/index.css";
|
||||
|
||||
createRoot(document.getElementById("money-root")).render(
|
||||
<React.StrictMode>
|
||||
<MoneyApp />
|
||||
</React.StrictMode>
|
||||
);
|
||||
402
money/src/model/data.js
Normal file
402
money/src/model/data.js
Normal file
@ -0,0 +1,402 @@
|
||||
export const moneyOperators = [
|
||||
{ id: "mina-chen", label: "Mina Chen", department: "平台运营组" },
|
||||
{ id: "sarah-ali", label: "Sarah Ali", department: "中东运营组" },
|
||||
{ id: "leo-wang", label: "Leo Wang", department: "拉美拓展组" },
|
||||
{ id: "diego-costa", label: "Diego Costa", department: "直播增长组" },
|
||||
];
|
||||
|
||||
export const moneyCountries = [
|
||||
{ code: "AE", currency: "AED", label: "阿联酋", region: "MENA" },
|
||||
{ code: "SA", currency: "SAR", label: "沙特", region: "MENA" },
|
||||
{ code: "BR", currency: "BRL", label: "巴西", region: "LATAM" },
|
||||
{ code: "MX", currency: "MXN", label: "墨西哥", region: "LATAM" },
|
||||
{ code: "ID", currency: "IDR", label: "印尼", region: "SEA" },
|
||||
{ code: "TR", currency: "TRY", label: "土耳其", region: "EMEA" },
|
||||
];
|
||||
|
||||
export const moneyAppOptions = [
|
||||
["", "全部 App"],
|
||||
["hy-live", "HY Live"],
|
||||
["voice-party", "Voice Party"],
|
||||
["social-pro", "Social Pro"],
|
||||
["spark-chat", "Spark Chat"],
|
||||
];
|
||||
|
||||
export const moneyScopeOptions = [
|
||||
["", "全部模块"],
|
||||
["app", "App 模块"],
|
||||
["region", "App 地区模块"],
|
||||
];
|
||||
|
||||
export const moneyProviderOptions = [
|
||||
{ code: "mifapay", label: "MiFaPay", countries: ["AE", "SA", "TR"] },
|
||||
{ code: "v5pay", label: "V5Pay", countries: ["BR", "MX", "ID"] },
|
||||
{ code: "stripe", label: "Stripe", countries: ["AE", "SA", "BR", "MX", "ID", "TR"] },
|
||||
];
|
||||
|
||||
export const trendLabels = ["06/21", "06/22", "06/23", "06/24", "06/25", "06/26", "06/27"];
|
||||
|
||||
export const moneyModules = [
|
||||
{
|
||||
id: "hy-live-app",
|
||||
appCode: "hy-live",
|
||||
appName: "HY Live",
|
||||
moduleName: "HY Live 全站",
|
||||
scope: "app",
|
||||
countryCode: "ALL",
|
||||
countryName: "全部国家",
|
||||
region: "Global",
|
||||
operatorId: "mina-chen",
|
||||
operatorName: "Mina Chen",
|
||||
department: "平台运营组",
|
||||
rechargeUsd: 148420,
|
||||
revenueUsd: 316800,
|
||||
salaryUsd: 64100,
|
||||
paidUsers: 7620,
|
||||
newUsers: 42850,
|
||||
dau: 960400,
|
||||
dayTargetUsd: 150000,
|
||||
monthActualUsd: 3410000,
|
||||
monthTargetUsd: 3800000,
|
||||
retention1: 44.8,
|
||||
retention7: 21.4,
|
||||
retention30: 9.2,
|
||||
linkCount: 46,
|
||||
paidLinkCount: 31,
|
||||
coinIssued: 15180000,
|
||||
rechargeTrend: [112000, 124500, 118900, 133400, 141200, 139800, 148420],
|
||||
revenueTrend: [246000, 258400, 249900, 281200, 297800, 301200, 316800],
|
||||
},
|
||||
{
|
||||
id: "voice-party-ksa",
|
||||
appCode: "voice-party",
|
||||
appName: "Voice Party",
|
||||
moduleName: "Voice Party / 沙特",
|
||||
scope: "region",
|
||||
countryCode: "SA",
|
||||
countryName: "沙特",
|
||||
region: "MENA-KSA",
|
||||
operatorId: "sarah-ali",
|
||||
operatorName: "Sarah Ali",
|
||||
department: "中东运营组",
|
||||
rechargeUsd: 76500,
|
||||
revenueUsd: 164200,
|
||||
salaryUsd: 29800,
|
||||
paidUsers: 3090,
|
||||
newUsers: 12800,
|
||||
dau: 286400,
|
||||
dayTargetUsd: 72000,
|
||||
monthActualUsd: 1820000,
|
||||
monthTargetUsd: 1750000,
|
||||
retention1: 47.5,
|
||||
retention7: 24.1,
|
||||
retention30: 10.8,
|
||||
linkCount: 29,
|
||||
paidLinkCount: 22,
|
||||
coinIssued: 7820000,
|
||||
rechargeTrend: [54800, 61200, 59400, 68000, 70400, 73900, 76500],
|
||||
revenueTrend: [126000, 132100, 129600, 145800, 151200, 158000, 164200],
|
||||
},
|
||||
{
|
||||
id: "voice-party-uae",
|
||||
appCode: "voice-party",
|
||||
appName: "Voice Party",
|
||||
moduleName: "Voice Party / 阿联酋",
|
||||
scope: "region",
|
||||
countryCode: "AE",
|
||||
countryName: "阿联酋",
|
||||
region: "MENA-UAE",
|
||||
operatorId: "sarah-ali",
|
||||
operatorName: "Sarah Ali",
|
||||
department: "中东运营组",
|
||||
rechargeUsd: 43800,
|
||||
revenueUsd: 88200,
|
||||
salaryUsd: 17200,
|
||||
paidUsers: 2020,
|
||||
newUsers: 7100,
|
||||
dau: 155300,
|
||||
dayTargetUsd: 52000,
|
||||
monthActualUsd: 930000,
|
||||
monthTargetUsd: 1200000,
|
||||
retention1: 41.6,
|
||||
retention7: 18.7,
|
||||
retention30: 7.9,
|
||||
linkCount: 18,
|
||||
paidLinkCount: 10,
|
||||
coinIssued: 3980000,
|
||||
rechargeTrend: [39100, 40200, 38800, 42100, 43700, 43000, 43800],
|
||||
revenueTrend: [76900, 80200, 78800, 83500, 86100, 87300, 88200],
|
||||
},
|
||||
{
|
||||
id: "social-pro-brazil",
|
||||
appCode: "social-pro",
|
||||
appName: "Social Pro",
|
||||
moduleName: "Social Pro / 巴西",
|
||||
scope: "region",
|
||||
countryCode: "BR",
|
||||
countryName: "巴西",
|
||||
region: "LATAM-BR",
|
||||
operatorId: "leo-wang",
|
||||
operatorName: "Leo Wang",
|
||||
department: "拉美拓展组",
|
||||
rechargeUsd: 58200,
|
||||
revenueUsd: 126300,
|
||||
salaryUsd: 22100,
|
||||
paidUsers: 4200,
|
||||
newUsers: 21100,
|
||||
dau: 418700,
|
||||
dayTargetUsd: 50000,
|
||||
monthActualUsd: 1260000,
|
||||
monthTargetUsd: 1080000,
|
||||
retention1: 39.2,
|
||||
retention7: 16.5,
|
||||
retention30: 6.8,
|
||||
linkCount: 34,
|
||||
paidLinkCount: 26,
|
||||
coinIssued: 6200000,
|
||||
rechargeTrend: [42100, 43800, 47200, 50100, 52700, 54100, 58200],
|
||||
revenueTrend: [89200, 93400, 102200, 109800, 115600, 118500, 126300],
|
||||
},
|
||||
{
|
||||
id: "social-pro-mexico",
|
||||
appCode: "social-pro",
|
||||
appName: "Social Pro",
|
||||
moduleName: "Social Pro / 墨西哥",
|
||||
scope: "region",
|
||||
countryCode: "MX",
|
||||
countryName: "墨西哥",
|
||||
region: "LATAM-MX",
|
||||
operatorId: "leo-wang",
|
||||
operatorName: "Leo Wang",
|
||||
department: "拉美拓展组",
|
||||
rechargeUsd: 31600,
|
||||
revenueUsd: 70600,
|
||||
salaryUsd: 14300,
|
||||
paidUsers: 2380,
|
||||
newUsers: 9400,
|
||||
dau: 180200,
|
||||
dayTargetUsd: 36000,
|
||||
monthActualUsd: 720000,
|
||||
monthTargetUsd: 820000,
|
||||
retention1: 36.4,
|
||||
retention7: 14.8,
|
||||
retention30: 5.9,
|
||||
linkCount: 15,
|
||||
paidLinkCount: 9,
|
||||
coinIssued: 3320000,
|
||||
rechargeTrend: [27600, 28100, 29400, 30200, 31800, 30500, 31600],
|
||||
revenueTrend: [60400, 62100, 64100, 66600, 68900, 68200, 70600],
|
||||
},
|
||||
{
|
||||
id: "spark-chat-indonesia",
|
||||
appCode: "spark-chat",
|
||||
appName: "Spark Chat",
|
||||
moduleName: "Spark Chat / 印尼",
|
||||
scope: "region",
|
||||
countryCode: "ID",
|
||||
countryName: "印尼",
|
||||
region: "SEA-ID",
|
||||
operatorId: "diego-costa",
|
||||
operatorName: "Diego Costa",
|
||||
department: "直播增长组",
|
||||
rechargeUsd: 39800,
|
||||
revenueUsd: 90200,
|
||||
salaryUsd: 18600,
|
||||
paidUsers: 3510,
|
||||
newUsers: 18400,
|
||||
dau: 322600,
|
||||
dayTargetUsd: 42000,
|
||||
monthActualUsd: 860000,
|
||||
monthTargetUsd: 940000,
|
||||
retention1: 43.1,
|
||||
retention7: 19.3,
|
||||
retention30: 8.1,
|
||||
linkCount: 21,
|
||||
paidLinkCount: 14,
|
||||
coinIssued: 4460000,
|
||||
rechargeTrend: [33800, 35600, 36100, 38200, 39100, 38800, 39800],
|
||||
revenueTrend: [76400, 80100, 81200, 84900, 87600, 88400, 90200],
|
||||
},
|
||||
];
|
||||
|
||||
export const initialPaymentLinks = [
|
||||
createPaymentLinkRecord({
|
||||
amountUsd: 299,
|
||||
countryCode: "SA",
|
||||
moduleId: "voice-party-ksa",
|
||||
now: new Date("2026-06-27T08:32:00+08:00"),
|
||||
operatorName: "Sarah Ali",
|
||||
providerCode: "mifapay",
|
||||
status: "paid",
|
||||
}),
|
||||
createPaymentLinkRecord({
|
||||
amountUsd: 129,
|
||||
countryCode: "BR",
|
||||
moduleId: "social-pro-brazil",
|
||||
now: new Date("2026-06-27T10:18:00+08:00"),
|
||||
operatorName: "Leo Wang",
|
||||
providerCode: "v5pay",
|
||||
status: "redirected",
|
||||
}),
|
||||
createPaymentLinkRecord({
|
||||
amountUsd: 499,
|
||||
countryCode: "AE",
|
||||
moduleId: "voice-party-uae",
|
||||
now: new Date("2026-06-27T11:04:00+08:00"),
|
||||
operatorName: "Sarah Ali",
|
||||
providerCode: "mifapay",
|
||||
status: "redirected",
|
||||
}),
|
||||
];
|
||||
|
||||
export function filterMoneyModules({
|
||||
activeOperatorId = "",
|
||||
appCode = "",
|
||||
countryCode = "",
|
||||
mode = "finance",
|
||||
modules = moneyModules,
|
||||
operatorKeyword = "",
|
||||
scope = "",
|
||||
} = {}) {
|
||||
const normalizedKeyword = operatorKeyword.trim().toLowerCase();
|
||||
|
||||
return modules.filter((module) => {
|
||||
const matchesRole = mode !== "operator" || module.operatorId === activeOperatorId;
|
||||
const matchesApp = !appCode || module.appCode === appCode;
|
||||
const matchesScope = !scope || module.scope === scope;
|
||||
const matchesCountry =
|
||||
!countryCode || module.countryCode === countryCode || (module.scope === "app" && module.countryCode === "ALL");
|
||||
const matchesOperator =
|
||||
!normalizedKeyword ||
|
||||
module.operatorName.toLowerCase().includes(normalizedKeyword) ||
|
||||
module.department.toLowerCase().includes(normalizedKeyword);
|
||||
|
||||
return matchesRole && matchesApp && matchesScope && matchesCountry && matchesOperator;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeMoneyModules(modules = []) {
|
||||
const rechargeUsd = sumBy(modules, "rechargeUsd");
|
||||
const revenueUsd = sumBy(modules, "revenueUsd");
|
||||
const salaryUsd = sumBy(modules, "salaryUsd");
|
||||
const paidUsers = sumBy(modules, "paidUsers");
|
||||
const newUsers = sumBy(modules, "newUsers");
|
||||
const dau = sumBy(modules, "dau");
|
||||
const dayTargetUsd = sumBy(modules, "dayTargetUsd");
|
||||
const monthActualUsd = sumBy(modules, "monthActualUsd");
|
||||
const monthTargetUsd = sumBy(modules, "monthTargetUsd");
|
||||
|
||||
return {
|
||||
arppuUsd: paidUsers > 0 ? rechargeUsd / paidUsers : 0,
|
||||
coinIssued: sumBy(modules, "coinIssued"),
|
||||
dau,
|
||||
dayProgress: percentage(rechargeUsd, dayTargetUsd),
|
||||
dayTargetUsd,
|
||||
grossProfitUsd: revenueUsd - salaryUsd,
|
||||
linkConversion: percentage(sumBy(modules, "paidLinkCount"), sumBy(modules, "linkCount")),
|
||||
monthActualUsd,
|
||||
monthProgress: percentage(monthActualUsd, monthTargetUsd),
|
||||
monthTargetUsd,
|
||||
newUsers,
|
||||
paidLinkCount: sumBy(modules, "paidLinkCount"),
|
||||
paidUsers,
|
||||
rechargeTrend: sumTrend(modules, "rechargeTrend"),
|
||||
rechargeUsd,
|
||||
retention1: averageBy(modules, "retention1"),
|
||||
retention7: averageBy(modules, "retention7"),
|
||||
retention30: averageBy(modules, "retention30"),
|
||||
revenueTrend: sumTrend(modules, "revenueTrend"),
|
||||
revenueUsd,
|
||||
salaryUsd,
|
||||
totalLinkCount: sumBy(modules, "linkCount"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPaymentLinkRecord({
|
||||
amountUsd,
|
||||
countryCode,
|
||||
moduleId,
|
||||
now = new Date(),
|
||||
operatorName,
|
||||
providerCode,
|
||||
status = "redirected",
|
||||
}) {
|
||||
const module = moneyModules.find((item) => item.id === moduleId) || moneyModules[0];
|
||||
const country = moneyCountries.find((item) => item.code === countryCode) || moneyCountries[0];
|
||||
const provider = moneyProviderOptions.find((item) => item.code === providerCode) || moneyProviderOptions[0];
|
||||
const createdAtMs = now.getTime();
|
||||
const suffix = compactTimestamp(now);
|
||||
const orderId = `MNY-${country.code}-${suffix}`;
|
||||
|
||||
return {
|
||||
amountUsd: Number(amountUsd) || 0,
|
||||
appName: module.appName,
|
||||
countryCode: country.code,
|
||||
countryName: country.label,
|
||||
createdAtMs,
|
||||
currency: country.currency,
|
||||
id: orderId,
|
||||
moduleId: module.id,
|
||||
moduleName: module.moduleName,
|
||||
operatorName: operatorName || module.operatorName,
|
||||
payUrl: `https://pay.hyapp.example/${country.code.toLowerCase()}/${orderId.toLowerCase()}`,
|
||||
providerCode: provider.code,
|
||||
providerName: provider.label,
|
||||
region: country.region,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
export function availableCountriesForModule(moduleId) {
|
||||
const module = moneyModules.find((item) => item.id === moduleId);
|
||||
if (!module || module.scope === "app" || module.countryCode === "ALL") {
|
||||
return moneyCountries;
|
||||
}
|
||||
return moneyCountries.filter((country) => country.code === module.countryCode);
|
||||
}
|
||||
|
||||
export function availableProvidersForCountry(countryCode) {
|
||||
return moneyProviderOptions.filter((provider) => provider.countries.includes(countryCode));
|
||||
}
|
||||
|
||||
export function paymentStatusLabel(status) {
|
||||
if (status === "paid") {
|
||||
return "已支付";
|
||||
}
|
||||
if (status === "redirected") {
|
||||
return "已生成";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function sumBy(items, key) {
|
||||
return items.reduce((total, item) => total + Number(item[key] || 0), 0);
|
||||
}
|
||||
|
||||
function averageBy(items, key) {
|
||||
if (!items.length) {
|
||||
return 0;
|
||||
}
|
||||
return sumBy(items, key) / items.length;
|
||||
}
|
||||
|
||||
function sumTrend(items, key) {
|
||||
return trendLabels.map((_, index) => items.reduce((total, item) => total + Number(item[key]?.[index] || 0), 0));
|
||||
}
|
||||
|
||||
function percentage(value, total) {
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(value) / Number(total)) * 100;
|
||||
}
|
||||
|
||||
function compactTimestamp(date) {
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(
|
||||
date.getMinutes(),
|
||||
)}${pad(date.getSeconds())}`;
|
||||
}
|
||||
46
money/src/model/data.test.js
Normal file
46
money/src/model/data.test.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
availableProvidersForCountry,
|
||||
createPaymentLinkRecord,
|
||||
filterMoneyModules,
|
||||
moneyModules,
|
||||
summarizeMoneyModules,
|
||||
} from "./data.js";
|
||||
|
||||
test("filters all modules for finance and owned modules for operators", () => {
|
||||
expect(filterMoneyModules({ mode: "finance" })).toHaveLength(moneyModules.length);
|
||||
expect(filterMoneyModules({ activeOperatorId: "sarah-ali", mode: "operator" }).map((module) => module.id)).toEqual([
|
||||
"voice-party-ksa",
|
||||
"voice-party-uae",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps app-level modules visible when filtering by country", () => {
|
||||
const modules = filterMoneyModules({ countryCode: "BR", mode: "finance" });
|
||||
|
||||
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "social-pro-brazil"]);
|
||||
});
|
||||
|
||||
test("summarizes visible modules for KPI cards and progress bars", () => {
|
||||
const summary = summarizeMoneyModules(filterMoneyModules({ activeOperatorId: "leo-wang", mode: "operator" }));
|
||||
|
||||
expect(summary.rechargeUsd).toBe(89800);
|
||||
expect(summary.totalLinkCount).toBe(49);
|
||||
expect(summary.monthProgress).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("creates country payment link records with available provider metadata", () => {
|
||||
const link = createPaymentLinkRecord({
|
||||
amountUsd: 129,
|
||||
countryCode: "BR",
|
||||
moduleId: "social-pro-brazil",
|
||||
now: new Date("2026-06-27T00:00:00Z"),
|
||||
providerCode: "v5pay",
|
||||
});
|
||||
|
||||
expect(link.id).toMatch(/^MNY-BR-/);
|
||||
expect(link.countryName).toBe("巴西");
|
||||
expect(link.providerName).toBe("V5Pay");
|
||||
expect(link.payUrl).toContain("/br/");
|
||||
expect(availableProvidersForCountry("BR").map((provider) => provider.code)).toContain("v5pay");
|
||||
});
|
||||
32
money/src/model/format.js
Normal file
32
money/src/model/format.js
Normal file
@ -0,0 +1,32 @@
|
||||
export function formatUsd(value, options = {}) {
|
||||
const digits = options.compact ? 1 : 2;
|
||||
return `US$ ${Number(value || 0).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
export function formatPercent(value, digits = 1) {
|
||||
return `${Number(value || 0).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
})}%`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
const pad = (part) => String(part).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
||||
date.getMinutes(),
|
||||
)}`;
|
||||
}
|
||||
1166
money/src/styles/index.css
Normal file
1166
money/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,7 @@ export const PERMISSIONS = {
|
||||
coinSellerUpdate: "coin-seller:update",
|
||||
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",
|
||||
|
||||
@ -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));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user