新数据指标

This commit is contained in:
zhx 2026-07-03 19:52:18 +08:00
parent 6ed13c690c
commit cb4dcd87cf
26 changed files with 5063 additions and 2612 deletions

View File

@ -11,7 +11,7 @@ import { ReportOverview } from "./components/ReportOverview.jsx";
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
import { SocialBiDashboard } from "./components/SocialBiDashboard.jsx";
import { SocialBiApp } from "./social/SocialBiApp.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
@ -23,7 +23,7 @@ const initialFilterOptions = {
export function DatabiApp() {
if (isSocialBiPath()) {
return <SocialBiDashboard />;
return <SocialBiApp />;
}
return <DatabiOverviewApp />;
}

View File

@ -322,31 +322,44 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
await flushEffects();
// v2 Shell + tab
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
["经营概览", "地区洞察", "人员绩效", "留存质量", "数据明细"].forEach((name) => {
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
});
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
expect(screen.getByText("付费用户")).toBeTruthy();
expect(screen.getByText("游戏流水/利润率")).toBeTruthy();
expect(screen.getByText("消耗产出比")).toBeTruthy();
expect(screen.getByText("ARPU ($)")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
expect(screen.getAllByText("300,000").length).toBeGreaterThan(0);
expect(screen.getAllByText("17.0%").length).toBeGreaterThan(0);
expect(screen.getAllByText("2分").length).toBeGreaterThan(0);
await act(async () => {
screen.getByRole("tab", { name: "地区分析" }).click();
});
// App Hero 12,300 + 5,000 USD = $173
// App Lalu App $123 Top5
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
expect(screen.getAllByText("充值 ($)").length).toBeGreaterThan(0);
expect(screen.getByText("付费用户")).toBeTruthy();
expect(screen.getAllByText("$173").length).toBeGreaterThan(0);
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
// Aslan $50
await act(async () => {
screen.getByRole("tab", { name: "人员绩效" }).click();
within(viewTabs).getByRole("tab", { name: "地区洞察" }).click();
});
expect(within(viewTabs).getByRole("tab", { name: "地区洞察" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("区域下钻")).toBeTruthy();
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
// Omar 90.0%kpi_manage
await act(async () => {
within(viewTabs).getByRole("tab", { name: "人员绩效" }).click();
});
expect(within(viewTabs).getByRole("tab", { name: "人员绩效" })).toHaveAttribute("aria-selected", "true");
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "配置 KPI 目标" })).toBeTruthy();
expect(screen.getAllByText(/90.0%|90,000/).length).toBeGreaterThan(0);
expect(screen.getAllByText("90.0%").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "配置目标" })).toBeTruthy();
});
async function flushEffects() {

View File

@ -1,11 +1,11 @@
import { useEffect, useRef } from "react";
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent, VisualMapComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import worldMap from "../data/world.json";
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, CanvasRenderer]);
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, VisualMapComponent, CanvasRenderer]);
echarts.registerMap("databi-world", worldMap);
export function EChart({ className = "", option }) {

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +0,0 @@
export const ALL_VALUE = "all";
// 以下 options 只是接口不可用时的兜底;真实的 App/人员/部门/地区目录来自
// GET /admin/databi/social/master按登录用户数据范围裁剪
export const socialBiAppOptions = [
{ label: "全部 App", value: ALL_VALUE },
{ label: "Lalu", value: "lalu" },
{ label: "Aslan", value: "aslan" },
{ label: "Yumi", value: "yumi" }
];
export const socialBiOperatorOptions = [{ label: "全部人员", searchText: "all", value: ALL_VALUE }];
export const socialBiDepartmentOptions = [{ label: "全部部门", value: ALL_VALUE }];
export const socialBiRegionOptions = [];
export const socialBiFilterDefinitions = {
apps: { label: "App", options: socialBiAppOptions, type: "multi" },
dateRange: { label: "日期区间", type: "dateRange" },
departments: { label: "运营部门", options: socialBiDepartmentOptions, type: "multi" },
operators: { label: "运营人员", options: socialBiOperatorOptions, placeholder: "搜索员工姓名", type: "searchMulti" },
regions: { label: "地区", options: socialBiRegionOptions, type: "cascade" }
};
export const socialBiSections = [
{
cards: [
{ key: "recharge", label: "总充值 ($)", type: "money" },
{ key: "newUsers", label: "总新增人数", type: "number" },
{ key: "dau", label: "总日活 (DAU)", type: "number" },
{ key: "arppu", label: "综合 ARPPU ($)", type: "money" }
],
columns: [
textColumn("date", "日期"),
textColumn("appName", "App名称"),
numberColumn("newUsers", "新增"),
numberColumn("dau", "日活"),
numberColumn("paidUsers", "付费用户"),
numberColumn("rechargeUsers", "充值用户"),
moneyColumn("recharge", "充值 ($)"),
moneyColumn("newUserRecharge", "新用户充值"),
moneyColumn("coinSellerRecharge", "币商充值"),
numberColumn("coinSellerStockCoin", "币商充值金币"),
numberColumn("coinSellerTransferCoin", "币商出货金币"),
moneyColumn("googleRecharge", "谷歌充值"),
moneyColumn("thirdPartyRecharge", "三方充值"),
flowRateColumn("gameFlowProfit", "游戏流水/利润率"),
flowRateColumn("luckyGiftFlowProfit", "幸运礼物流水/利润率"),
flowRateColumn("superLuckyGiftFlowProfit", "超级幸运礼物流水/利润率"),
numberColumn("giftCoinSpent", "礼物流水"),
numberColumn("coins", "金币数量"),
numberColumn("consumedCoin", "消耗金币"),
numberColumn("outputCoin", "产出金币"),
consumeOutputColumn("consumeOutputRatio", "消耗产出比"),
numberColumn("platformGrantCoin", "平台发放金币"),
numberColumn("manualGrantCoin", "人工发放金币"),
moneyColumn("salary", "存量工资 ($)"),
numberColumn("salaryTransferCoin", "工资兑换金币"),
durationColumn("avgMicOnlineMs", "平均麦上时间", "average"),
durationColumn("micOnlineMs", "麦上总时长"),
moneyColumn("arpu", "ARPU ($)", "average"),
moneyColumn("arppu", "ARPPU ($)", "average"),
percentColumn("paidConversionRate", "付费转化率"),
percentColumn("rechargeConversionRate", "充值转化率"),
moneyColumn("turnover", "App流水 ($)"),
percentColumn("retentionD1", "次日留存"),
percentColumn("retentionD7", "7日留存"),
percentColumn("retentionD30", "30日留存")
],
filters: ["dateRange", "apps", "operators"],
key: "appData",
label: "App 数据",
rows: [],
title: "App 数据"
},
{
cards: [
{ key: "recharge", label: "部门区间充值 ($)", type: "money" },
{ key: "monthRecharge", label: "部门当月充值 ($)", type: "money" },
{ key: "newUsers", label: "部门区间新增", type: "number" },
{ key: "monthKpiRate", label: "当月 KPI 达成率 (%)", type: "percent" }
],
columns: [
textColumn("date", "日期"),
textColumn("department", "部门"),
textColumn("appName", "App名称"),
textColumn("region", "负责地区"),
moneyColumn("recharge", "充值 ($)"),
numberColumn("newUsers", "新增"),
progressColumn("dayProgress", "当日目标完成进度"),
progressColumn("monthProgress", "当月目标完成进度")
],
filters: ["dateRange", "apps", "departments"],
key: "department",
label: "部门看板",
rows: [],
title: "部门看板"
},
{
cards: [
{ key: "recharge", label: "人员区间充值合计 ($)", type: "money" },
{ key: "monthRecharge", label: "人员当月充值合计 ($)", type: "money" },
{ key: "monthTarget", label: "当月目标合计 ($)", type: "money" },
{ key: "monthKpiRate", label: "当月充值 KPI 达成率 (%)", type: "percent" }
],
columns: [
textColumn("date", "日期"),
textColumn("operator", "运营人员"),
textColumn("appName", "App名称"),
textColumn("region", "所管地区"),
moneyColumn("recharge", "充值 ($)"),
numberColumn("newUsers", "新增"),
progressColumn("dayProgress", "当日目标完成进度"),
progressColumn("monthProgress", "当月目标完成进度")
],
filters: ["dateRange", "apps", "operators"],
key: "personnel",
label: "人员绩效",
rows: [],
title: "人员绩效"
},
{
cards: [
{ key: "recharge", label: "区域总充值 ($)", type: "money" },
{ key: "newUsers", label: "区域总新增", type: "number" },
{ key: "arppu", label: "区域综合 ARPPU ($)", type: "money" }
],
columns: [
textColumn("date", "日期"),
textColumn("region", "地区"),
textColumn("appName", "App名称"),
moneyColumn("recharge", "充值 ($)"),
moneyColumn("turnover", "流水 ($)"),
moneyColumn("arppu", "ARPPU ($)", "average"),
numberColumn("newUsers", "新增"),
numberColumn("dau", "日活"),
percentColumn("retentionD1", "次留"),
percentColumn("retentionD7", "7日留"),
percentColumn("retentionD30", "30日留"),
moneyColumn("salary", "存量工资 ($)"),
moneyColumn("googleRecharge", "谷歌充值"),
moneyColumn("thirdPartyRecharge", "三方充值"),
moneyColumn("newUserRecharge", "新用户充值"),
numberColumn("giftCoinSpent", "礼物流水"),
flowRateColumn("luckyGiftFlowProfit", "幸运礼物流水/利润率"),
numberColumn("coinSellerTransferCoin", "币商出货金币")
],
filters: ["dateRange", "apps", "regions"],
key: "regional",
label: "地区分析",
rows: [],
title: "地区分析"
},
{
cards: [
{ key: "newUsers", label: "总新增", type: "number" },
{ key: "dau", label: "总日活", type: "number" },
{ key: "retentionD1", label: "次日留存均值 (%)", type: "percent" },
{ key: "retentionD7", label: "7日留存均值 (%)", type: "percent" }
],
columns: [
textColumn("date", "日期"),
textColumn("region", "地区"),
textColumn("appName", "App名称"),
numberColumn("newUsers", "新增"),
numberColumn("dau", "日活 (DAU)"),
percentColumn("retentionD1", "次日留存"),
percentColumn("retentionD7", "7日留存"),
percentColumn("retentionD30", "30日留存"),
percentColumn("paidConversionRate", "付费转化率"),
moneyColumn("arpu", "ARPU ($)", "average"),
moneyColumn("arppu", "ARPPU ($)", "average")
],
filters: ["dateRange", "apps", "regions"],
key: "retention",
label: "留存分析",
rows: [],
title: "留存分析"
}
];
function textColumn(key, label) {
return { aggregate: "text", align: "left", key, label, type: "text" };
}
function moneyColumn(key, label, aggregate = "sum") {
return { aggregate, align: "right", key, label, type: "money" };
}
function numberColumn(key, label) {
return { aggregate: "sum", align: "right", key, label, type: "number" };
}
function percentColumn(key, label) {
return { aggregate: "average", align: "right", key, label, type: "percent" };
}
function progressColumn(key, label) {
return { aggregate: "progress", align: "left", key, label, type: "progress" };
}
function flowRateColumn(key, label) {
return { aggregate: "flowRate", align: "right", key, label, type: "flowRate" };
}
function consumeOutputColumn(key, label) {
return { aggregate: "consumeOutput", align: "right", key, label, type: "consumeOutput" };
}
function durationColumn(key, label, aggregate = "sum") {
return { aggregate: aggregate === "average" ? "durationAverage" : "duration", align: "right", key, label, type: "duration" };
}

View File

@ -0,0 +1,312 @@
// BI v2 Shell+ /App//
// useSocialBi()
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import RepeatOutlined from "@mui/icons-material/RepeatOutlined";
import TableChartOutlined from "@mui/icons-material/TableChartOutlined";
import {
ALL,
DATE_PRESETS,
GRANULARITIES,
createDefaultFilters,
rangeLabel,
readStateFromURL,
toggleMultiValue,
writeStateToURL
} from "./state.js";
import { appColor } from "./metrics.js";
import { useSocialBiData } from "./useSocialBiData.js";
import { OverviewView } from "./views/OverviewView.jsx";
import { RegionsView } from "./views/RegionsView.jsx";
import { TeamKpiView } from "./views/TeamKpiView.jsx";
import { RetentionView } from "./views/RetentionView.jsx";
import { DataTableView } from "./views/DataTableView.jsx";
import "../styles/social-v2.css";
const VIEWS = [
{ component: OverviewView, icon: InsightsOutlined, key: "overview", label: "经营概览" },
{ component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" },
{ component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "人员绩效" },
{ component: RetentionView, icon: RepeatOutlined, key: "retention", label: "留存质量" },
{ component: DataTableView, icon: TableChartOutlined, key: "table", label: "数据明细" }
];
const SocialBiContext = createContext(null);
export function useSocialBi() {
const context = useContext(SocialBiContext);
if (!context) {
throw new Error("useSocialBi must be used inside SocialBiApp");
}
return context;
}
export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters);
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
const data = useSocialBiData(filters);
useEffect(() => {
writeStateToURL(filters, viewKey);
}, [filters, viewKey]);
const updateFilter = useCallback((key, value) => {
setFilters((current) => ({ ...current, [key]: value }));
}, []);
const resetFilters = useCallback(() => {
setFilters(createDefaultFilters());
}, []);
const contextValue = useMemo(
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
[data, filters, resetFilters, updateFilter, viewKey]
);
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
return (
<SocialBiContext.Provider value={contextValue}>
<div className="sbi-shell">
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
<div className="sbi-main">
<TopBar data={data} filters={filters} updateFilter={updateFilter} />
{data.errors.length ? (
<div className="sbi-error-banner" role="alert">
{data.errors.map((message, index) => (
<span key={`${index}-${message}`}>{message}</span>
))}
</div>
) : null}
<main className="sbi-view" aria-label="Social BI 数据中心">
<ActiveView />
</main>
</div>
</div>
</SocialBiContext.Provider>
);
}
function Sidebar({ data, onViewChange, viewKey }) {
const access = data.master?.access;
const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`;
return (
<aside className="sbi-sidebar" aria-label="Social BI 导航">
<div className="sbi-brand">
<span className="sbi-brand-mark">HY</span>
<div className="sbi-brand-copy">
<strong>社交 BI</strong>
<small>Social Analytics</small>
</div>
</div>
<nav className="sbi-nav" role="tablist" aria-label="数据视图">
{VIEWS.map((view) => {
const Icon = view.icon;
const isActive = viewKey === view.key;
return (
<button
aria-selected={isActive}
className={isActive ? "sbi-nav-item is-active" : "sbi-nav-item"}
key={view.key}
onClick={() => onViewChange(view.key)}
role="tab"
type="button"
>
<Icon fontSize="small" />
<span>{view.label}</span>
</button>
);
})}
</nav>
{scopeLabel ? (
<div className="sbi-scope-badge" title="数据按你的负责范围裁剪,可在用户管理里调整">
<span className="sbi-scope-dot" />
{scopeLabel}
</div>
) : null}
</aside>
);
}
function TopBar({ data, filters, updateFilter }) {
return (
<header className="sbi-topbar">
<div className="sbi-topbar-row">
<DatePresetControl filters={filters} updateFilter={updateFilter} />
<GranularityControl filters={filters} updateFilter={updateFilter} />
<button className="sbi-refresh" onClick={data.refresh} title="刷新数据" type="button">
<RefreshOutlined fontSize="small" />
</button>
</div>
<div className="sbi-topbar-row">
<AppChips data={data} filters={filters} updateFilter={updateFilter} />
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
</div>
</header>
);
}
function DatePresetControl({ filters, updateFilter }) {
return (
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
{DATE_PRESETS.map((preset) => (
<button
aria-checked={filters.preset === preset.key}
className={filters.preset === preset.key ? "is-active" : ""}
key={preset.key}
onClick={() => updateFilter("preset", preset.key)}
role="radio"
type="button"
>
{preset.label}
</button>
))}
{filters.preset === "custom" ? (
<span className="sbi-custom-range">
<input
aria-label="开始日期"
onChange={(event) => updateFilter("customStart", event.target.value)}
type="date"
value={filters.customStart}
/>
<span></span>
<input
aria-label="结束日期"
onChange={(event) => updateFilter("customEnd", event.target.value)}
type="date"
value={filters.customEnd}
/>
</span>
) : null}
</div>
);
}
function GranularityControl({ filters, updateFilter }) {
return (
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
{GRANULARITIES.map((item) => (
<button
aria-checked={filters.granularity === item.key}
className={filters.granularity === item.key ? "is-active" : ""}
key={item.key}
onClick={() => updateFilter("granularity", item.key)}
role="radio"
type="button"
>
{item.label}
</button>
))}
</div>
);
}
function AppChips({ data, filters, updateFilter }) {
const apps = data.master?.apps || [];
const allCodes = apps.map((app) => app.app_code);
const isAll = filters.apps.includes(ALL);
return (
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
<button
aria-pressed={isAll}
className={isAll ? "sbi-chip is-active" : "sbi-chip"}
onClick={() => updateFilter("apps", [ALL])}
type="button"
>
全部 App
</button>
{apps.map((app) => {
const selected = !isAll && filters.apps.includes(app.app_code);
return (
<button
aria-pressed={selected}
className={selected ? "sbi-chip is-active" : "sbi-chip"}
key={app.app_code}
onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
type="button"
>
<span className="sbi-chip-dot" style={{ background: appColor(app.app_code, allCodes) }} />
{app.app_name || app.app_code}
</button>
);
})}
</div>
);
}
function RegionSelect({ data, filters, updateFilter }) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
const apps = data.master?.apps || [];
const regions = data.master?.regions || [];
const groups = apps
.map((app) => ({
app,
regions: regions.filter((region) => region.app_code === app.app_code)
}))
.filter((group) => group.regions.length);
useEffect(() => {
const handlePointerDown = (event) => {
if (!containerRef.current?.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, []);
const selectedCount = filters.regions.includes(ALL) ? 0 : filters.regions.length;
return (
<div className="sbi-region-select" ref={containerRef}>
<button aria-expanded={isOpen} className="sbi-region-trigger" onClick={() => setIsOpen((open) => !open)} type="button">
地区{selectedCount ? `已选 ${selectedCount}` : "全部"}
</button>
{isOpen ? (
<div className="sbi-region-menu" role="listbox" aria-label="地区筛选">
<button
className={filters.regions.includes(ALL) ? "sbi-region-option is-active" : "sbi-region-option"}
onClick={() => updateFilter("regions", [ALL])}
type="button"
>
全部地区
</button>
{groups.map((group) => (
<div className="sbi-region-group" key={group.app.app_code}>
<button
className={
filters.regions.includes(`app:${group.app.app_code}`) ? "sbi-region-option is-group is-active" : "sbi-region-option is-group"
}
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
type="button"
>
{group.app.app_name || group.app.app_code} · 全部区域
</button>
{group.regions.map((region) => {
const value = `${region.app_code}:${region.region_id}`;
return (
<button
className={filters.regions.includes(value) ? "sbi-region-option is-active" : "sbi-region-option"}
key={value}
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, value))}
type="button"
>
{region.region_name || region.region_code}
</button>
);
})}
</div>
))}
{!groups.length ? <p className="sbi-region-empty">暂无区域目录</p> : null}
</div>
) : null}
</div>
);
}

121
databi/src/social/format.js Normal file
View File

@ -0,0 +1,121 @@
// 社交 BI v2 的展示格式化层:所有视图共用,保证同一指标在任何视图里的写法一致。
// 后端金额一律是 USD 分(*_usd_minor比例一律是 0-1 小数,这里统一换算。
const numberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const decimalFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 1 });
export function isBlank(value) {
return value === null || value === undefined || value === "" || (typeof value === "number" && !Number.isFinite(value));
}
export function formatMoneyMinor(value) {
if (isBlank(value)) {
return "--";
}
return formatMoney(Number(value) / 100);
}
export function formatMoney(value) {
if (isBlank(value)) {
return "--";
}
const numeric = Number(value);
const digits = Math.abs(numeric) < 1000 && !Number.isInteger(numeric) ? 1 : 0;
return `$${numeric.toLocaleString("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits })}`;
}
export function formatCount(value) {
if (isBlank(value)) {
return "--";
}
return numberFormatter.format(Number(value));
}
// 金币等大数值在卡片/图表标签里用紧凑写法1.2亿 视觉负担太大,用 en 缩写与后台其余模块一致)。
export function formatCompact(value) {
if (isBlank(value)) {
return "--";
}
const numeric = Number(value);
const abs = Math.abs(numeric);
if (abs >= 1_000_000_000) {
return `${(numeric / 1_000_000_000).toFixed(1)}B`;
}
if (abs >= 1_000_000) {
return `${(numeric / 1_000_000).toFixed(1)}M`;
}
if (abs >= 10_000) {
return `${(numeric / 1_000).toFixed(1)}K`;
}
return numberFormatter.format(numeric);
}
export function formatRatioPercent(value) {
if (isBlank(value)) {
return "--";
}
return `${decimalFormatter.format(Number(value) * 100)}%`;
}
export function formatPercentValue(value) {
if (isBlank(value)) {
return "--";
}
return `${decimalFormatter.format(Number(value))}%`;
}
export function formatDurationMs(value) {
if (isBlank(value)) {
return "--";
}
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}`;
}
const totalMinutes = Math.round(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes ? `${hours}小时${minutes}` : `${hours}小时`;
}
// 环比:后端 delta 字段是 0-1 增幅0.12 = +12%)。
export function formatDeltaRate(value) {
if (isBlank(value)) {
return "";
}
const numeric = Number(value) * 100;
const sign = numeric >= 0 ? "+" : "";
return `${sign}${decimalFormatter.format(numeric)}%`;
}
export function deltaDirection(value) {
if (isBlank(value) || Number(value) === 0) {
return "flat";
}
return Number(value) > 0 ? "up" : "down";
}
export function csvEscape(value) {
const text = String(value ?? "");
if (/[",\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
export function downloadCsv(filename, headerCells, rows) {
const lines = [headerCells.map(csvEscape).join(",")];
rows.forEach((cells) => {
lines.push(cells.map(csvEscape).join(","));
});
const blob = new Blob(["\uFEFF", lines.join("\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,267 @@
// 社交 BI v2 的指标口径中心:所有视图通过 METRIC_DEFS 取 label/格式化/口径说明,
// 行数据保持后端原始 snake_case 字段(金额 USD 分、比例 0-1渲染时才格式化。
import { formatCompact, formatCount, formatDurationMs, formatMoneyMinor, formatRatioPercent, isBlank } from "./format.js";
export const METRIC_DEFS = {
new_users: { label: "新增", tooltip: "当日新注册用户数legacy 按 user_run_profile 创建时间)", type: "count" },
active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" },
paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" },
recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" },
recharge_usd_minor: { label: "充值 ($)", tooltip: "全部渠道充值合计USD", type: "money" },
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "当日注册用户当日的充值金额", type: "money" },
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Google Play 渠道充值", type: "money" },
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "非谷歌的三方渠道充值合计", type: "money" },
coin_seller_recharge_usd_minor: { label: "币商充值", tooltip: "币商进货充值金额", type: "money" },
coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" },
coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币legacy 按 SELLER_AGENT 流水)", type: "coin" },
game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" },
game_profit_rate: { label: "游戏利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
lucky_gift_turnover: { label: "幸运礼物流水", tooltip: "幸运礼物赠送金币流水", type: "coin" },
lucky_gift_payout: { label: "幸运礼物返奖", tooltip: "幸运礼物中奖返还金币", type: "coin" },
lucky_gift_profit_rate: { label: "幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
super_lucky_gift_turnover: { label: "超级幸运礼物流水", tooltip: "超级幸运礼物赠送金币流水legacy 平台无此玩法)", type: "coin" },
super_lucky_gift_profit_rate: { label: "超级幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
gift_coin_spent: { label: "礼物流水", tooltip: "全部礼物赠送金币流水(含幸运礼物)", type: "coin" },
coin_total: { label: "金币数量", tooltip: "用户金币余额快照", type: "coin" },
consumed_coin: { label: "消耗金币", tooltip: "金币消耗流水合计legacy 仅全 App 口径)", type: "coin" },
output_coin: { label: "产出金币", tooltip: "金币产出流水合计legacy 仅全 App 口径)", type: "coin" },
consume_output_ratio: { label: "消耗产出比", tooltip: "消耗金币 / 产出金币", type: "ratio" },
platform_grant_coin: { label: "平台发放金币", tooltip: "平台活动发放金币", type: "coin" },
manual_grant_coin: { label: "人工发放金币", tooltip: "后台人工发放金币", type: "coin" },
salary_usd_minor: { label: "存量工资 ($)", tooltip: "主播工资余额快照USD", type: "money" },
salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" },
avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" },
mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" },
arpu_usd_minor: { label: "ARPU ($)", tooltip: "充值 / 日活", type: "money" },
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "充值 / 付费用户", type: "money" },
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
d7_retention_rate: { label: "7日留存", tooltip: "注册第 7 日仍活跃的比例", type: "ratio" },
d30_retention_rate: { label: "30日留存", tooltip: "注册第 30 日仍活跃的比例", type: "ratio" }
};
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
export const METRIC_GROUPS = [
{ key: "users", label: "用户", metrics: ["new_users", "active_users", "paid_users", "recharge_users"] },
{
key: "recharge",
label: "充值",
metrics: [
"recharge_usd_minor",
"new_user_recharge_usd_minor",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"coin_seller_recharge_usd_minor"
]
},
{ key: "coinSeller", label: "币商", metrics: ["coin_seller_stock_coin", "coin_seller_transfer_coin"] },
{
key: "play",
label: "玩法流水",
metrics: [
"game_turnover",
"game_profit_rate",
"lucky_gift_turnover",
"lucky_gift_profit_rate",
"super_lucky_gift_turnover",
"super_lucky_gift_profit_rate",
"gift_coin_spent"
]
},
{
key: "coins",
label: "金币",
metrics: ["coin_total", "consumed_coin", "output_coin", "consume_output_ratio", "platform_grant_coin", "manual_grant_coin"]
},
{ key: "salary", label: "工资", metrics: ["salary_usd_minor", "salary_transfer_coin"] },
{ key: "engagement", label: "时长", metrics: ["avg_mic_online_ms", "mic_online_ms"] },
{
key: "quality",
label: "质量",
metrics: [
"arpu_usd_minor",
"arppu_usd_minor",
"paid_conversion_rate",
"recharge_conversion_rate",
"d1_retention_rate",
"d7_retention_rate",
"d30_retention_rate"
]
}
];
export function metricLabel(key) {
return METRIC_DEFS[key]?.label || key;
}
export function metricTooltip(key) {
return METRIC_DEFS[key]?.tooltip || "";
}
export function formatMetric(key, value) {
const type = METRIC_DEFS[key]?.type;
if (type === "money") {
return formatMoneyMinor(value);
}
if (type === "ratio") {
return formatRatioPercent(value);
}
if (type === "coin") {
return formatCount(value);
}
if (type === "duration") {
return formatDurationMs(value);
}
return formatCount(value);
}
export function formatMetricCompact(key, value) {
const type = METRIC_DEFS[key]?.type;
if (type === "money") {
return isBlank(value) ? "--" : `$${formatCompact(Number(value) / 100)}`;
}
if (type === "ratio") {
return formatRatioPercent(value);
}
if (type === "duration") {
return formatDurationMs(value);
}
return formatCompact(value);
}
// 图表取数值:金额换算成美元,比例换算成百分数,其余原样;缺失返回 null 让 ECharts 断线。
export function metricChartValue(key, value) {
if (isBlank(value)) {
return null;
}
const type = METRIC_DEFS[key]?.type;
if (type === "money") {
return Number(value) / 100;
}
if (type === "ratio") {
return Number(value) * 100;
}
return Number(value);
}
// 可线性相加的字段;与后端 databi/metrics.go 的 additiveMetricKeys 对齐,前端聚合只做同一口径。
const ADDITIVE_METRIC_KEYS = [
"new_users",
"active_users",
"paid_users",
"recharge_users",
"recharge_usd_minor",
"new_user_recharge_usd_minor",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"game_turnover",
"game_payout",
"lucky_gift_turnover",
"lucky_gift_payout",
"super_lucky_gift_turnover",
"super_lucky_gift_payout",
"gift_coin_spent",
"coin_total",
"consumed_coin",
"output_coin",
"platform_grant_coin",
"manual_grant_coin",
"salary_usd_minor",
"salary_transfer_coin",
"mic_online_ms",
"mic_online_users",
"d1_retention_users",
"d1_retention_base_users",
"d7_retention_users",
"d7_retention_base_users",
"d30_retention_users",
"d30_retention_base_users"
];
function readNumeric(row, key) {
const value = row?.[key];
if (isBlank(value)) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
// 余额快照类指标只在"空间"维度可加(跨国家/区域/App跨天求和没有业务含义
// 跨天聚合时取最后一天的快照(与后端整段口径一致)。
const SNAPSHOT_METRIC_KEYS = new Set(["coin_total", "salary_usd_minor"]);
// aggregateMetricMaps 把若干行App 合计、区域行、日行)合并成一行:
// 可加字段求和(全缺失保持 null → 显示 "--"),比例/均值按分子分母重算,避免"平均百分比"这类错误口径。
// 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。
export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
const out = {};
let latestDay = "";
if (acrossTime) {
rows.forEach((row) => {
const day = String(row?.stat_day || "");
if (day > latestDay) {
latestDay = day;
}
});
}
ADDITIVE_METRIC_KEYS.forEach((key) => {
const snapshotOnly = acrossTime && SNAPSHOT_METRIC_KEYS.has(key);
let sum = null;
rows.forEach((row) => {
if (snapshotOnly && String(row?.stat_day || "") !== latestDay) {
return;
}
const value = readNumeric(row, key);
if (value !== null) {
sum = (sum ?? 0) + value;
}
});
out[key] = sum;
});
const recharge = out.recharge_usd_minor;
const active = out.active_users;
const paid = out.paid_users;
out.arpu_usd_minor = recharge !== null && active ? Math.round(recharge / active) : null;
out.arppu_usd_minor = recharge !== null && paid ? Math.round(recharge / paid) : null;
out.paid_conversion_rate = paid !== null && active ? paid / active : null;
out.recharge_conversion_rate = out.recharge_users !== null && active ? out.recharge_users / active : null;
out.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null;
out.lucky_gift_profit_rate = out.lucky_gift_turnover
? (out.lucky_gift_turnover - (out.lucky_gift_payout ?? 0)) / out.lucky_gift_turnover
: null;
out.super_lucky_gift_profit_rate = out.super_lucky_gift_turnover
? (out.super_lucky_gift_turnover - (out.super_lucky_gift_payout ?? 0)) / out.super_lucky_gift_turnover
: null;
out.consume_output_ratio = out.consumed_coin !== null && out.output_coin ? out.consumed_coin / out.output_coin : null;
out.avg_mic_online_ms = out.mic_online_ms !== null && out.mic_online_users ? Math.round(out.mic_online_ms / out.mic_online_users) : null;
["d1", "d7", "d30"].forEach((day) => {
const users = out[`${day}_retention_users`];
const base = out[`${day}_retention_base_users`];
out[`${day}_retention_rate`] = users !== null && base ? users / base : null;
});
return out;
}
// App 品牌色chips、图表系列、行标识统一取色保证同一 App 全站同色。
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
const APP_COLOR_FIXED = { aslan: 2, fami: 3, huwaa: 4, lalu: 0, yumi: 1 };
export function appColor(appCode) {
const code = String(appCode || "").toLowerCase();
if (code in APP_COLOR_FIXED) {
return APP_COLOR_PALETTE[APP_COLOR_FIXED[code]];
}
let hash = 0;
for (let index = 0; index < code.length; index += 1) {
hash = (hash * 31 + code.charCodeAt(index)) >>> 0;
}
return APP_COLOR_PALETTE[hash % APP_COLOR_PALETTE.length];
}

206
databi/src/social/state.js Normal file
View File

@ -0,0 +1,206 @@
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度,
// 并同步到 URL query保证筛选状态可以直接作为链接分享。
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
export const ALL = "all";
export const DATE_PRESETS = [
{ key: "today", label: "今日" },
{ key: "yesterday", label: "昨日" },
{ key: "last7", label: "近 7 日" },
{ key: "last30", label: "近 30 日" },
{ key: "month", label: "本月" },
{ key: "prevMonth", label: "上月" },
{ key: "custom", label: "自定义" }
];
export const GRANULARITIES = [
{ key: "day", label: "日" },
{ key: "week", label: "周" },
{ key: "month", label: "月" }
];
export function createDefaultFilters() {
return {
apps: [ALL],
customEnd: "",
customStart: "",
granularity: "day",
preset: "yesterday",
regions: [ALL]
};
}
export function resolveDateRange(filters, timeZone) {
const today = todayRange(timeZone).start;
switch (filters.preset) {
case "today":
return { end: today, start: today };
case "yesterday": {
const yesterday = shiftDate(today, -1);
return { end: yesterday, start: yesterday };
}
case "last7": {
const range = lastDaysRange(7, timeZone);
return { end: range.end, start: range.start };
}
case "last30": {
const range = lastDaysRange(30, timeZone);
return { end: range.end, start: range.start };
}
case "month": {
const range = thisMonthRange(timeZone);
return { end: range.end, start: range.start };
}
case "prevMonth": {
const monthStart = thisMonthRange(timeZone).start;
const prevEnd = shiftDate(monthStart, -1);
return { end: prevEnd, start: `${prevEnd.slice(0, 7)}-01` };
}
case "custom": {
const start = normalizeDateText(filters.customStart) || shiftDate(today, -7);
const end = normalizeDateText(filters.customEnd) || today;
return end < start ? { end: start, start: end } : { end, start };
}
default: {
const yesterday = shiftDate(today, -1);
return { end: yesterday, start: yesterday };
}
}
}
export function rangeDayCount(range) {
const start = Date.parse(`${range.start}T00:00:00Z`);
const end = Date.parse(`${range.end}T00:00:00Z`);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
return 1;
}
return Math.round((end - start) / 86400000) + 1;
}
export function rangeLabel(range) {
return range.start === range.end ? range.start : `${range.start} ~ ${range.end}`;
}
export function shiftDate(dateText, days) {
const [year, month, day] = String(dateText).split("-").map(Number);
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, (day || 1) + days));
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
}
function normalizeDateText(value) {
const text = String(value || "").trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
}
function pad2(value) {
return String(value).padStart(2, "0");
}
// stat_day → 周/月粒度的分桶 key周取周一为锚点与运营周报口径一致。
export function bucketDay(statDay, granularity) {
if (granularity === "month") {
return String(statDay).slice(0, 7);
}
if (granularity === "week") {
const time = Date.parse(`${statDay}T00:00:00Z`);
if (!Number.isFinite(time)) {
return statDay;
}
const date = new Date(time);
const weekday = (date.getUTCDay() + 6) % 7;
date.setUTCDate(date.getUTCDate() - weekday);
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
}
return statDay;
}
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"];
export function readStateFromURL(location = window.location) {
const params = new URLSearchParams(location.search);
const filters = createDefaultFilters();
let view = "";
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
filters.preset = params.get("preset");
}
filters.customStart = normalizeDateText(params.get("start"));
filters.customEnd = normalizeDateText(params.get("end"));
if (params.get("apps")) {
filters.apps = params.get("apps").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
}
if (params.get("regions")) {
filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean);
}
if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) {
filters.granularity = params.get("granularity");
}
if (params.get("view")) {
view = params.get("view");
}
if (!filters.apps.length) {
filters.apps = [ALL];
}
if (!filters.regions.length) {
filters.regions = [ALL];
}
return { filters, view };
}
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
const params = new URLSearchParams(location.search);
URL_KEYS.forEach((key) => params.delete(key));
if (filters.preset !== "yesterday") {
params.set("preset", filters.preset);
}
if (filters.preset === "custom") {
if (filters.customStart) {
params.set("start", filters.customStart);
}
if (filters.customEnd) {
params.set("end", filters.customEnd);
}
}
if (filters.apps.length && !filters.apps.includes(ALL)) {
params.set("apps", filters.apps.join(","));
}
if (filters.regions.length && !filters.regions.includes(ALL)) {
params.set("regions", filters.regions.join(","));
}
if (filters.granularity !== "day") {
params.set("granularity", filters.granularity);
}
if (view && view !== "overview") {
params.set("view", view);
}
const query = params.toString();
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
}
// 区域选择值格式:整 App = `app:<appCode>`,单区域 = `<appCode>:<regionId>`。
export function regionSelectionMatches(selection, appCode, regionId) {
if (!selection?.length || selection.includes(ALL)) {
return true;
}
return selection.includes(`app:${appCode}`) || selection.includes(`${appCode}:${regionId}`);
}
export function appSelectionMatches(selection, appCode) {
if (!selection?.length || selection.includes(ALL)) {
return true;
}
return selection.includes(String(appCode || "").toLowerCase());
}
export function toggleMultiValue(current, value, allValues) {
if (value === ALL) {
return [ALL];
}
const withoutAll = current.filter((item) => item !== ALL);
const next = withoutAll.includes(value) ? withoutAll.filter((item) => item !== value) : [...withoutAll, value];
if (!next.length || (allValues?.length && allValues.every((item) => next.includes(item)))) {
return [ALL];
}
return next;
}

View File

@ -0,0 +1,204 @@
// 社交 BI v2 的数据层:负责 master/overview/kpi 三个接口的拉取与派生行,
// 视图不直接调 API只消费这里的产物原始 snake_case 指标行 + 维度字段)。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js";
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
export function useSocialBiData(filters) {
const [master, setMaster] = useState(null);
const [overview, setOverview] = useState(null);
const [kpi, setKpi] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState([]);
const [refreshToken, setRefreshToken] = useState(0);
const requestSeq = useRef(0);
useEffect(() => {
let ignore = false;
fetchSocialBiMaster()
.then((data) => {
if (!ignore && data) {
setMaster(data);
}
})
.catch(() => {
if (!ignore) {
setMaster(null);
}
});
return () => {
ignore = true;
};
}, []);
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
const { customEnd, customStart, preset } = filters;
const range = useMemo(
() => resolveDateRange({ customEnd, customStart, preset }, SOCIAL_BI_TZ),
[customEnd, customStart, preset]
);
const appCodes = useMemo(() => {
const available = (master?.apps || []).map((app) => app.app_code);
if (!filters.apps.length || filters.apps.includes(ALL)) {
return available;
}
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
}, [filters.apps, master]);
useEffect(() => {
const sequence = ++requestSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
setIsLoading(true);
Promise.allSettled([
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
]).then(([overviewResult, kpiResult]) => {
if (sequence !== requestSeq.current) {
return;
}
const nextErrors = [];
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
(overviewResult.value?.apps || []).forEach((app) => {
if (app.error) {
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
}
});
} else {
setOverview(null);
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
}
if (kpiResult.status === "fulfilled") {
setKpi(kpiResult.value);
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
nextErrors.push(`${appCode}: ${message}`);
});
} else {
setKpi(null);
nextErrors.push(`KPI 数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
}
setErrors(nextErrors);
setIsLoading(false);
});
}, [appCodes, range, refreshToken]);
const refresh = useCallback(() => {
setRefreshToken((current) => current + 1);
}, []);
const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]);
return {
appCodes,
derived,
errors,
isLoading,
kpi,
master,
overview,
range,
refresh
};
}
// deriveRows 输出四类行,全部带维度字段 + 原始指标字段:
// appTotals/appDailyApp 粒度、regionTotals/regionDaily大区粒度已按顶栏区域选择过滤
// countryTotals国家粒度已按国家→区域目录归一 region_id 并跟随区域筛选。restricted=true 表示该 App 已被数据范围裁剪。
function deriveRows(overview, filters, master) {
const appTotals = [];
const appDaily = [];
const regionTotals = [];
const regionDaily = [];
const countryTotals = [];
const appErrors = [];
const regionByCountry = buildRegionByCountryIndex(master);
(overview?.apps || []).forEach((app) => {
if (!appSelectionMatches(filters.apps, app.app_code)) {
return;
}
const context = {
app_code: app.app_code,
app_name: app.app_name || app.app_code,
kind: app.kind,
restricted: Boolean(app.restricted)
};
if (app.error) {
appErrors.push({ ...context, error: app.error });
return;
}
appTotals.push({ ...context, ...(app.total || {}) });
(app.daily_series || []).forEach((row) => {
appDaily.push({ ...context, ...row, stat_day: row.stat_day || row.label });
});
(app.region_breakdown || []).forEach((row) => {
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
return;
}
regionTotals.push({
...context,
...row,
region_id: Number(row.region_id ?? 0),
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`
});
});
(app.daily_region_breakdown || []).forEach((row) => {
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
return;
}
regionDaily.push({
...context,
...row,
region_id: Number(row.region_id ?? 0),
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`,
stat_day: row.stat_day || row.label
});
});
(app.country_breakdown || []).forEach((row) => {
const countryCode = row.country_code || String(row.country_id || "");
// legacy 国家行不带 region_id用区域目录countries 列表)归一,保证下钻与区域筛选口径一致。
let regionId = Number(row.region_id ?? 0);
let regionName = row.region_name || "";
if (!regionId) {
const region = regionByCountry.get(`${app.app_code}:${String(countryCode).toUpperCase()}`);
if (region) {
regionId = region.region_id;
regionName = region.region_name || region.region_code || regionName;
}
}
if (!regionSelectionMatches(filters.regions, app.app_code, regionId)) {
return;
}
countryTotals.push({
...context,
...row,
country_code: countryCode,
country_name: row.country_name || row.country || row.country_code || `国家 ${row.country_id}`,
region_id: regionId,
region_name: regionName
});
});
});
// 顶栏选了区域时App 合计/日行切换成所选区域行的聚合视角由各视图用 regionScoped 判断。
const regionScoped = Boolean(filters.regions.length && !filters.regions.includes(ALL));
return { appDaily, appErrors, appTotals, countryTotals, regionDaily, regionScoped, regionTotals };
}
function buildRegionByCountryIndex(master) {
const index = new Map();
(master?.regions || []).forEach((region) => {
(region.countries || []).forEach((countryCode) => {
const key = `${region.app_code}:${String(countryCode).toUpperCase()}`;
if (!index.has(key)) {
index.set(key, region);
}
});
});
return index;
}

View File

@ -0,0 +1,318 @@
// DataTableView Excel / / /
// / CSV snake_case metrics.js
import { useMemo, useState } from "react";
import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import { useSocialBi } from "../SocialBiApp.jsx";
import {
METRIC_GROUPS,
aggregateMetricMaps,
formatMetric,
metricChartValue,
metricLabel,
metricTooltip
} from "../metrics.js";
import { downloadCsv, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./data-table-view.css";
const GROUPS_STORAGE_KEY = "hyapp-admin.sbi-table-groups";
const MAX_RENDER_ROWS = 400;
const SKELETON_WIDTHS = ["42%", "100%", "96%", "91%", "86%", "81%"];
const EMPTY_ROWS = [];
// value totalText """"
const DIM_COLUMNS = {
app: { label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
country: { label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
day: { label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
region: { label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
};
// key derived
const DIMENSIONS = [
{ csvName: "app-daily", dims: ["day", "app"], hasDay: true, key: "appDaily", label: "App×日" },
{ csvName: "app-total", dims: ["app"], hasDay: false, key: "appTotals", label: "App 合计" },
{ csvName: "region-daily", dims: ["day", "app", "region"], hasDay: true, key: "regionDaily", label: "区域×日" },
{ csvName: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" },
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
];
function readClosedGroups() {
try {
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) {
const validKeys = new Set(METRIC_GROUPS.map((group) => group.key));
return parsed.filter((key) => validKeys.has(key));
}
} catch {
// localStorage 退
}
return [];
}
function writeClosedGroups(keys) {
try {
window.localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(keys));
} catch {
//
}
}
function metricSortValue(row, key) {
const value = row?.[key];
if (isBlank(value)) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
// null desc asc
function compareMetric(a, b, key, dir) {
const left = metricSortValue(a, key);
const right = metricSortValue(b, key);
const leftValue = left === null ? Number.NEGATIVE_INFINITY : left;
const rightValue = right === null ? Number.NEGATIVE_INFINITY : right;
if (leftValue === rightValue) {
return 0;
}
return dir === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
function rowIdentity(row, dimKey, index) {
const parts = [row.app_code, row.region_id, row.country_code, row.stat_day].filter(
(part) => part !== undefined && part !== null && part !== ""
);
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
}
export function DataTableView() {
const { derived, isLoading, range } = useSocialBi();
const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null);
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
const hasDay = dim.hasDay;
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
// 退
const activeSort = sort && visibleMetrics.includes(sort.key) ? sort : null;
// /react-hooks preserve-manual-memoization
// useMemo
const sortedRows = [...baseRows];
if (activeSort) {
sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
} else if (hasDay) {
sortedRows.sort((a, b) => {
if (a.stat_day !== b.stat_day) {
return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
}
return compareMetric(a, b, "recharge_usd_minor", "desc");
});
} else {
sortedRows.sort((a, b) => compareMetric(a, b, "recharge_usd_minor", "desc"));
}
// /
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
const hasRestricted = baseRows.some((row) => row.restricted);
const columnCount = dimColumns.length + visibleMetrics.length;
const handleDimChange = (key) => {
setDimKey(key);
setSort(null);
};
const toggleGroup = (key) => {
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
setClosedGroups(next);
writeClosedGroups(next);
};
const handleSort = (key) => {
setSort((current) => (current?.key === key ? { dir: current.dir === "desc" ? "asc" : "desc", key } : { dir: "desc", key }));
};
//
const handleExport = () => {
const header = [...dimColumns.map((column) => column.label), ...visibleMetrics.map((key) => metricLabel(key))];
const rows = sortedRows.map((row) => [
...dimColumns.map((column) => column.value(row)),
...visibleMetrics.map((key) => {
const value = metricChartValue(key, row[key]);
return value === null ? "" : value;
})
]);
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let content;
if (isLoading && !baseRows.length) {
content = (
<div className="sbi-dt-skeleton" aria-label="数据加载中">
{SKELETON_WIDTHS.map((width) => (
<span className="sbi-skeleton" key={width} style={{ width }} />
))}
</div>
);
} else if (!baseRows.length) {
content = (
<div className="sbi-empty">
<strong>当前维度暂无数据</strong>
<span>
{dim.dims.includes("region")
? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
: "试试更换日期区间、调整 App 筛选或切换其它维度"}
</span>
</div>
);
} else if (!visibleMetrics.length) {
content = (
<div className="sbi-empty">
<strong>所有列组已隐藏</strong>
<span>点击上方列组开关恢复需要查看的指标列</span>
</div>
);
} else {
content = (
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-table">
<thead>
<tr>
{dimColumns.map((column, index) => (
<th className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label} rowSpan={2}>
{column.label}
</th>
))}
{visibleGroups.map((group) => (
<th className="sbi-dt-group-th" colSpan={group.metrics.length} key={group.key}>
{group.label}
</th>
))}
</tr>
<tr>
{visibleMetrics.map((key) => {
const isSorted = activeSort?.key === key;
return (
<th
aria-sort={isSorted ? (activeSort.dir === "desc" ? "descending" : "ascending") : undefined}
className={isSorted ? "sbi-dt-metric-th is-sorted" : "sbi-dt-metric-th"}
key={key}
onClick={() => handleSort(key)}
>
<span className="sbi-metric-hint" title={metricTooltip(key)}>
{metricLabel(key)}
</span>
{isSorted ? <span className="sbi-dt-sort">{activeSort.dir === "desc" ? "↓" : "↑"}</span> : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{totalRow ? (
<tr className="is-total">
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{column.totalText}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, totalRow[key])}</td>
))}
</tr>
) : null}
{renderRows.map((row, rowIndex) => (
<tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{column.value(row)}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, row[key])}</td>
))}
</tr>
))}
</tbody>
{isTruncated ? (
<tfoot>
<tr className="sbi-dt-note">
<td colSpan={columnCount}>已截断 {sortedRows.length} 可导出 CSV 查看全部</td>
</tr>
</tfoot>
) : null}
</table>
</div>
);
}
return (
<section className="sbi-card">
<div className="sbi-card-header">
<strong>数据明细</strong>
<small>
{rangeLabel(range)} · {sortedRows.length}
</small>
</div>
<div className="sbi-dt-tools">
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
{DIMENSIONS.map((item) => (
<button
aria-checked={dim.key === item.key}
className={dim.key === item.key ? "is-active" : ""}
key={item.key}
onClick={() => handleDimChange(item.key)}
role="radio"
type="button"
>
{item.label}
</button>
))}
</div>
<div className="sbi-dt-groups" role="group" aria-label="列组开关">
<span className="sbi-dt-groups-label">列组</span>
{METRIC_GROUPS.map((group) => {
const isOpen = !closedGroups.includes(group.key);
return (
<button
aria-pressed={isOpen}
className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
key={group.key}
onClick={() => toggleGroup(group.key)}
title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
type="button"
>
{group.label}
</button>
);
})}
</div>
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
<div className="sbi-dt-export">
<Button
disabled={!sortedRows.length || !visibleMetrics.length}
onClick={handleExport}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
>
导出 CSV
</Button>
</div>
</div>
{content}
</section>
);
}

View File

@ -0,0 +1,489 @@
// OverviewView Hero KPIApp Top5
// SocialBiApp.jsx useSocialBi() API
import { useMemo, useState } from "react";
import { EChart } from "../../charts/EChart.jsx";
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { useSocialBi } from "../SocialBiApp.jsx";
import { bucketDay } from "../state.js";
import "./overview-view.css";
const HERO_METRICS = [
{ deltaKey: "recharge_delta_rate", key: "recharge_usd_minor" },
{ deltaKey: "new_users_delta_rate", key: "new_users" },
{ deltaKey: "active_users_delta_rate", key: "active_users" },
{ deltaKey: "paid_users_delta_rate", key: "paid_users" },
{ deltaKey: "arppu_delta_rate", key: "arppu_usd_minor" },
{ deltaKey: "", key: "paid_conversion_rate" }
];
const TREND_METRICS = [
{ key: "recharge_usd_minor", label: "充值" },
{ key: "new_users", label: "新增" },
{ key: "active_users", label: "日活" },
{ key: "gift_coin_spent", label: "礼物流水" }
];
const COMPARE_METRICS = [
{ key: "recharge_usd_minor", label: "充值" },
{ key: "active_users", label: "日活" },
{ key: "new_users", label: "新增" }
];
const CHANNEL_METRICS = ["google_recharge_usd_minor", "mifapay_recharge_usd_minor", "coin_seller_recharge_usd_minor"];
const CHANNEL_COLORS = ["#2557d6", "#0e7490", "#d97706"];
const MONEY_METRIC_KEYS = new Set([
"recharge_usd_minor",
"google_recharge_usd_minor",
"mifapay_recharge_usd_minor",
"coin_seller_recharge_usd_minor"
]);
// metricChartValue tooltip
function formatChartAmount(metricKey, value) {
if (isBlank(value)) {
return "--";
}
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
}
// bucketDay key
function buildBuckets(rows, granularity) {
const map = new Map();
rows.forEach((row) => {
const key = bucketDay(row.stat_day, granularity);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(row);
});
return [...map.keys()].sort().map((key) => ({ key, rows: map.get(key) }));
}
function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
return {
grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 },
legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 },
series,
tooltip: {
axisPointer: { type: "shadow" },
backgroundColor: "#ffffff",
borderColor: "#e3eaf3",
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
let total = null;
const lines = list.map((item) => {
const numeric = isBlank(item.value) || !Number.isFinite(Number(item.value)) ? null : Number(item.value);
if (numeric !== null) {
total = (total ?? 0) + numeric;
}
return `${item.marker}${item.seriesName}&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, numeric)}</b>`;
});
const header = list[0]?.axisValueLabel || list[0]?.name || "";
if (list.length > 1) {
lines.push(`合计&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, total)}</b>`);
}
return [header, ...lines].filter(Boolean).join("<br/>");
},
textStyle: { color: "#14263c", fontSize: 12 },
trigger: "axis"
},
xAxis: {
axisLabel: { color: "#5b7089", fontSize: 11 },
axisLine: { lineStyle: { color: "#eef3f9" } },
axisTick: { show: false },
data: bucketKeys,
type: "category"
},
yAxis: {
axisLabel: { color: "#5b7089", fontSize: 11 },
splitLine: { lineStyle: { color: "#eef3f9" } },
type: "value"
}
};
}
function buildSparkOption(spark) {
return {
grid: { bottom: 2, left: 0, right: 0, top: 2 },
series: [
{
areaStyle: { color: "rgba(37, 87, 214, 0.12)" },
data: spark.map((point) => point.value),
lineStyle: { color: "#2557d6", width: 2 },
showSymbol: false,
smooth: true,
symbol: "none",
type: "line"
}
],
xAxis: { boundaryGap: false, data: spark.map((point) => point.key), show: false, type: "category" },
yAxis: { show: false, type: "value" }
};
}
function HeroCard({ deltaValue, metricKey, spark, value }) {
const sparkOption = useMemo(() => buildSparkOption(spark), [spark]);
const hasSpark = spark.length > 1 && spark.some((point) => point.value !== null);
return (
<article className="sbi-card sbi-ov-hero-card">
<header className="sbi-ov-hero-top">
<span className="sbi-metric-hint sbi-ov-hero-label" title={metricTooltip(metricKey)}>
{metricLabel(metricKey)}
</span>
{!isBlank(deltaValue) ? (
<span className={`sbi-delta is-${deltaDirection(deltaValue)}`}>{formatDeltaRate(deltaValue)}</span>
) : null}
</header>
<strong className="sbi-ov-hero-value">{formatMetric(metricKey, value)}</strong>
<div className="sbi-ov-hero-spark">{hasSpark ? <EChart className="sbi-ov-spark-chart" option={sparkOption} /> : null}</div>
</article>
);
}
function OverviewSkeleton() {
return (
<section className="sbi-ov">
<div className="sbi-ov-hero">
{HERO_METRICS.map((metric) => (
<article className="sbi-card sbi-ov-hero-card" key={metric.key}>
<span className="sbi-skeleton" style={{ height: 14, width: 72 }} />
<span className="sbi-skeleton" style={{ height: 26, width: 110 }} />
<span className="sbi-skeleton" style={{ height: 38, width: "100%" }} />
</article>
))}
</div>
<section className="sbi-card sbi-ov-skeleton-card">
<span className="sbi-skeleton" style={{ height: 300, width: "100%" }} />
</section>
<div className="sbi-ov-duo">
<section className="sbi-card sbi-ov-skeleton-card">
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
</section>
<section className="sbi-card sbi-ov-skeleton-card">
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
</section>
</div>
<section className="sbi-card sbi-ov-skeleton-card">
<span className="sbi-skeleton" style={{ height: 180, width: "100%" }} />
</section>
</section>
);
}
export function OverviewView() {
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
// regionScoped App
const dailyRows = derived.regionScoped ? derived.regionDaily : derived.appDaily;
const totals = useMemo(
() => aggregateMetricMaps(derived.regionScoped ? derived.regionTotals : derived.appTotals),
[derived.appTotals, derived.regionScoped, derived.regionTotals]
);
const buckets = useMemo(() => buildBuckets(dailyRows, filters.granularity), [dailyRows, filters.granularity]);
const bucketAggregates = useMemo(
() => buckets.map((bucket) => ({ key: bucket.key, metrics: aggregateMetricMaps(bucket.rows) })),
[buckets]
);
const heroSparks = useMemo(() => {
const map = {};
HERO_METRICS.forEach((metric) => {
map[metric.key] = bucketAggregates.map((bucket) => ({
key: bucket.key,
value: metricChartValue(metric.key, bucket.metrics[metric.key])
}));
});
return map;
}, [bucketAggregates]);
// App × 使App appCodes
const appBuckets = useMemo(() => {
const keys = buckets.map((bucket) => bucket.key);
const appIndex = new Map();
buckets.forEach((bucket, bucketIdx) => {
const byApp = new Map();
bucket.rows.forEach((row) => {
if (!byApp.has(row.app_code)) {
byApp.set(row.app_code, []);
}
byApp.get(row.app_code).push(row);
});
byApp.forEach((rows, code) => {
if (!appIndex.has(code)) {
appIndex.set(code, { app_code: code, app_name: rows[0].app_name, cells: new Array(buckets.length).fill(null) });
}
appIndex.get(code).cells[bucketIdx] = aggregateMetricMaps(rows);
});
});
const apps = [...appIndex.values()].sort((a, b) => appCodes.indexOf(a.app_code) - appCodes.indexOf(b.app_code));
return { apps, keys };
}, [appCodes, buckets]);
const trendOption = useMemo(() => {
const series = appBuckets.apps.map((app) => ({
barMaxWidth: 26,
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
itemStyle: { color: appColor(app.app_code, appCodes) },
name: app.app_name,
stack: "total",
type: "bar"
}));
return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric });
}, [appBuckets, appCodes, trendMetric]);
const channelOption = useMemo(() => {
const series = CHANNEL_METRICS.map((key, index) => ({
barMaxWidth: 26,
data: bucketAggregates.map((bucket) => metricChartValue(key, bucket.metrics[key])),
itemStyle: { color: CHANNEL_COLORS[index] },
name: metricLabel(key),
stack: "channel",
type: "bar"
}));
return buildStackedBarOption({ bucketKeys: bucketAggregates.map((bucket) => bucket.key), series, valueMetric: CHANNEL_METRICS[0] });
}, [bucketAggregates]);
const hasChannelData = useMemo(
() => bucketAggregates.some((bucket) => CHANNEL_METRICS.some((key) => Number(bucket.metrics[key] ?? 0) > 0)),
[bucketAggregates]
);
const compareRows = useMemo(() => {
let rows;
if (derived.regionScoped) {
const byApp = new Map();
derived.regionTotals.forEach((row) => {
if (!byApp.has(row.app_code)) {
byApp.set(row.app_code, { app_code: row.app_code, app_name: row.app_name, rows: [] });
}
byApp.get(row.app_code).rows.push(row);
});
rows = [...byApp.values()].map((entry) => ({
app_code: entry.app_code,
app_name: entry.app_name,
metrics: aggregateMetricMaps(entry.rows)
}));
} else {
rows = derived.appTotals.map((row) => ({ app_code: row.app_code, app_name: row.app_name, metrics: row }));
}
return rows.sort((a, b) => {
const left = Number(a.metrics[compareMetric]);
const right = Number(b.metrics[compareMetric]);
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0);
});
}, [compareMetric, derived.appTotals, derived.regionScoped, derived.regionTotals]);
const compareTotal = compareRows.reduce((sum, row) => {
const value = Number(row.metrics[compareMetric]);
return Number.isFinite(value) && value > 0 ? sum + value : sum;
}, 0);
const topRegions = useMemo(() => {
return [...derived.regionTotals]
.sort((a, b) => (Number(b.recharge_usd_minor) || 0) - (Number(a.recharge_usd_minor) || 0))
.slice(0, 5);
}, [derived.regionTotals]);
const topRegionMax = topRegions.reduce((max, row) => Math.max(max, Number(row.recharge_usd_minor) || 0), 0);
const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null;
const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0;
const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || "";
if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) {
return <OverviewSkeleton />;
}
return (
<section className="sbi-ov">
{derived.appErrors.length || hasRestricted ? (
<div className="sbi-ov-info">
{derived.appErrors.map((item) => (
<span className="sbi-badge is-warn" key={item.app_code}>
{item.app_name}: {item.error}
</span>
))}
{hasRestricted ? <span className="sbi-badge">已按你的数据范围过滤</span> : null}
</div>
) : null}
<div className="sbi-ov-hero">
{HERO_METRICS.map((metric) => (
<HeroCard
deltaValue={soloApp && metric.deltaKey ? soloApp[metric.deltaKey] : null}
key={metric.key}
metricKey={metric.key}
spark={heroSparks[metric.key]}
value={totals[metric.key]}
/>
))}
</div>
<section className="sbi-card">
<header className="sbi-card-header">
<strong>{trendLabel}趋势</strong>
<small> App 堆叠</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
{TREND_METRICS.map((item) => (
<button
aria-checked={trendMetric === item.key}
className={trendMetric === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setTrendMetric(item.key)}
role="radio"
title={metricTooltip(item.key)}
type="button"
>
{item.label}
</button>
))}
</div>
</div>
</header>
<div className="sbi-ov-card-body">
{hasTrend ? (
<EChart className="sbi-ov-trend-chart" option={trendOption} />
) : (
<div className="sbi-empty">
<strong>暂无趋势数据</strong>
<span>当前筛选范围内没有日级数据试试放宽日期或 App 筛选</span>
</div>
)}
</div>
</section>
<div className="sbi-ov-duo">
<section className="sbi-card">
<header className="sbi-card-header">
<strong>渠道构成</strong>
<small>谷歌 / 三方 / 币商充值</small>
</header>
<div className="sbi-ov-card-body">
{hasChannelData ? (
<EChart className="sbi-ov-channel-chart" option={channelOption} />
) : (
<div className="sbi-empty">
<strong>暂无渠道拆分数据</strong>
<span>所选范围内各渠道充值均为空或 0</span>
</div>
)}
</div>
</section>
<section className="sbi-card">
<header className="sbi-card-header">
<strong>App 对比</strong>
<small title={metricTooltip(compareMetric)}>{COMPARE_METRICS.find((item) => item.key === compareMetric)?.label}占比</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="对比指标">
{COMPARE_METRICS.map((item) => (
<button
aria-checked={compareMetric === item.key}
className={compareMetric === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setCompareMetric(item.key)}
role="radio"
title={metricTooltip(item.key)}
type="button"
>
{item.label}
</button>
))}
</div>
</div>
</header>
<div className="sbi-ov-card-body">
{compareRows.length ? (
<div className="sbi-ov-compare">
{compareRows.map((row) => {
const raw = Number(row.metrics[compareMetric]);
const share = compareTotal > 0 && Number.isFinite(raw) && raw > 0 ? (raw / compareTotal) * 100 : 0;
const color = appColor(row.app_code, appCodes);
return (
<div className="sbi-ov-compare-row" key={row.app_code}>
<span className="sbi-ov-compare-name">
<span className="sbi-ov-dot" style={{ background: color }} />
{row.app_name}
</span>
<div className="sbi-ov-bar-track">
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
</div>
<span className="sbi-ov-num">{formatMetric(compareMetric, row.metrics[compareMetric])}</span>
<button
className="sbi-ov-link"
onClick={() => {
updateFilter("apps", [row.app_code]);
setViewKey("regions");
}}
type="button"
>
地区
</button>
</div>
);
})}
</div>
) : (
<div className="sbi-empty">
<strong>暂无 App 数据</strong>
<span>调整顶部 App / 日期筛选后重试</span>
</div>
)}
</div>
</section>
</div>
<section className="sbi-card">
<header className="sbi-card-header">
<strong>区域 Top5</strong>
<small title={metricTooltip("recharge_usd_minor")}>按充值降序</small>
<div className="sbi-card-toolbar">
<button className="sbi-ov-link" onClick={() => setViewKey("regions")} type="button">
查看全部
</button>
</div>
</header>
<div className="sbi-ov-card-body">
{topRegions.length ? (
<div className="sbi-ov-top">
{topRegions.map((row) => {
const raw = Number(row.recharge_usd_minor);
const share = topRegionMax > 0 && Number.isFinite(raw) && raw > 0 ? (raw / topRegionMax) * 100 : 0;
const color = appColor(row.app_code, appCodes);
return (
<div className="sbi-ov-top-row" key={`${row.app_code}:${row.region_id}`}>
<span className="sbi-ov-top-name">
<strong>{row.region_name}</strong>
<span className="sbi-badge">
<span className="sbi-ov-dot" style={{ background: color }} />
{row.app_name}
</span>
</span>
<div className="sbi-ov-bar-track">
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
</div>
<span className="sbi-ov-num">{formatMetric("recharge_usd_minor", row.recharge_usd_minor)}</span>
</div>
);
})}
</div>
) : (
<div className="sbi-empty">
<strong>暂无区域数据</strong>
<span>该范围未配置区域目录或你没有对应区域的数据权限</span>
</div>
)}
</div>
</section>
</section>
);
}

View File

@ -0,0 +1,465 @@
// RegionsView + + +
// SocialBiApp.jsx useSocialBi() USD 0-1 metrics/format
import { Fragment, useMemo, useState } from "react";
import { VisualMapComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { EChart } from "../../charts/EChart.jsx";
import { resolveCountryMeta } from "../../data/countryMeta.js";
import worldMap from "../../data/world.json";
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { useSocialBi } from "../SocialBiApp.jsx";
import "./regions-view.css";
// EChart.jsx visualMap echarts.use
echarts.use([VisualMapComponent]);
const MAP_METRICS = [
{ key: "recharge_usd_minor", label: "充值" },
{ key: "new_users", label: "新增" },
{ key: "active_users", label: "日活" }
];
const CARD_METRICS = ["new_users", "active_users", "arppu_usd_minor"];
const TABLE_METRICS = [
"recharge_usd_minor",
"new_users",
"active_users",
"paid_users",
"arppu_usd_minor",
"d1_retention_rate",
"gift_coin_spent"
];
// databi-world feature name ISO alpha-2 /
const MAP_NAME_INDEX = new Map();
(worldMap.features || []).forEach((feature) => {
const name = feature?.properties?.name;
if (name) {
MAP_NAME_INDEX.set(String(name).trim().toLowerCase(), name);
}
});
// countryMeta ISO alpha-2 / databi-world feature name
function resolveMapName(countryCode, countryName) {
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
const candidates = [...(meta?.names || []), countryName];
for (const candidate of candidates) {
const hit = MAP_NAME_INDEX.get(String(candidate || "").trim().toLowerCase());
if (hit) {
return hit;
}
}
return "";
}
function countryDisplayName(countryCode, countryName) {
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
return meta?.label || countryName || countryCode || "未知国家";
}
function rechargeValue(row) {
return isBlank(row?.recharge_usd_minor) ? -1 : Number(row.recharge_usd_minor);
}
function byRechargeDesc(a, b) {
return rechargeValue(b) - rechargeValue(a);
}
export function RegionsView() {
const { appCodes, derived, isLoading } = useSocialBi();
const { appTotals, countryTotals, regionTotals } = derived;
const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
const [expandedRegions, setExpandedRegions] = useState(() => new Set());
const regionRows = useMemo(() => [...regionTotals].sort(byRechargeDesc), [regionTotals]);
const regionSummary = useMemo(() => aggregateMetricMaps(regionTotals), [regionTotals]);
const regionCount = useMemo(
() => new Set(regionTotals.map((row) => `${row.app_code}:${row.region_id}`)).size,
[regionTotals]
);
const appCount = useMemo(() => new Set(regionTotals.map((row) => row.app_code)).size, [regionTotals]);
const countryCount = useMemo(() => {
const codes = new Set();
countryTotals.forEach((row) => {
const key = String(row.country_code || row.country_name || "").trim();
if (key) {
codes.add(key);
}
});
return codes.size;
}, [countryTotals]);
// App appTotals App 退
const appRechargeTotals = useMemo(() => {
const totals = new Map();
appTotals.forEach((row) => {
if (!isBlank(row.recharge_usd_minor)) {
totals.set(row.app_code, Number(row.recharge_usd_minor));
}
});
const fallback = new Map();
regionTotals.forEach((row) => {
if (!isBlank(row.recharge_usd_minor)) {
fallback.set(row.app_code, (fallback.get(row.app_code) || 0) + Number(row.recharge_usd_minor));
}
});
fallback.forEach((value, code) => {
if (!totals.has(code)) {
totals.set(code, value);
}
});
return totals;
}, [appTotals, regionTotals]);
// countryTotals app_code + region_id region_id 0""
const countriesByRegion = useMemo(() => {
const groups = new Map();
countryTotals.forEach((row) => {
const key = `${row.app_code}:${Number(row.region_id ?? 0)}`;
const bucket = groups.get(key) || [];
bucket.push(row);
groups.set(key, bucket);
});
groups.forEach((rows) => rows.sort(byRechargeDesc));
return groups;
}, [countryTotals]);
// App aggregateMetricMaps
const mapIndex = useMemo(() => {
const buckets = new Map();
const unmatched = new Set();
countryTotals.forEach((row) => {
const mapName = resolveMapName(row.country_code, row.country_name);
if (!mapName) {
unmatched.add(String(row.country_code || row.country_name || "?"));
return;
}
const bucket = buckets.get(mapName) || { label: countryDisplayName(row.country_code, row.country_name), rows: [] };
bucket.rows.push(row);
buckets.set(mapName, bucket);
});
const entries = new Map();
buckets.forEach((bucket, mapName) => {
entries.set(mapName, { label: bucket.label, metrics: aggregateMetricMaps(bucket.rows) });
});
return { entries, unmatchedCount: unmatched.size };
}, [countryTotals]);
const mapOption = useMemo(() => {
const data = [];
mapIndex.entries.forEach((entry, mapName) => {
const value = metricChartValue(mapMetric, entry.metrics[mapMetric]);
if (value !== null) {
data.push({ name: mapName, value });
}
});
const max = data.reduce((acc, item) => Math.max(acc, item.value), 0) || 1;
return {
series: [
{
data,
emphasis: { itemStyle: { areaColor: "#93b6f2" }, label: { show: false } },
itemStyle: { areaColor: "#e9eef5", borderColor: "#d5dfeb", borderWidth: 0.6 },
label: { show: false },
map: "databi-world",
name: metricLabel(mapMetric),
roam: false,
select: { disabled: true },
type: "map"
}
],
tooltip: {
backgroundColor: "#ffffff",
borderColor: "#e3eaf3",
extraCssText: "box-shadow: 0 4px 16px rgba(16, 34, 56, 0.12);",
formatter: (params) => {
const entry = mapIndex.entries.get(params.name);
if (!entry) {
return `${params.name}<br/>暂无数据`;
}
return `${entry.label}<br/>${metricLabel(mapMetric)}${formatMetric(mapMetric, entry.metrics[mapMetric])}`;
},
textStyle: { color: "#14263c", fontSize: 12 }
},
visualMap: {
bottom: 10,
calculable: false,
inRange: { color: ["#dbeafe", "#2557d6"] },
itemHeight: 110,
itemWidth: 10,
left: 12,
max,
min: 0,
orient: "horizontal",
text: ["高", "低"],
textStyle: { color: "#5b7089", fontSize: 11 }
}
};
}, [mapIndex, mapMetric]);
const toggleRegion = (key) => {
setExpandedRegions((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
if (isLoading && !regionTotals.length && !countryTotals.length) {
return <RegionsSkeleton />;
}
if (!regionTotals.length) {
return (
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无区域数据</strong>
<span>检查顶栏区域筛选或该 App 尚未配置区域目录legacy App 在其 Mongo sys_region_confighyapp 在区域管理</span>
</div>
</section>
);
}
return (
<>
<div className="sbi-rg-summary">
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label">覆盖区域数</span>
<span className="sbi-rg-summary-value">{formatCount(regionCount)}</span>
<span className="sbi-rg-summary-sub"> App × 区域去重 · 覆盖 {formatCount(appCount)} App</span>
</section>
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label">覆盖国家数</span>
<span className="sbi-rg-summary-value">{formatCount(countryCount)}</span>
<span className="sbi-rg-summary-sub">countryTotals 按国家去重 App 合并</span>
</section>
<section className="sbi-card sbi-rg-summary-card">
<span className="sbi-rg-summary-label" title={metricTooltip("recharge_usd_minor")}>
区域充值合计
</span>
<span className="sbi-rg-summary-value">{formatMoneyMinor(regionSummary.recharge_usd_minor)}</span>
<span className="sbi-rg-summary-sub">当前区间 · 可见区域行合计</span>
</section>
</div>
<section className="sbi-card sbi-rg-map-card">
<div className="sbi-card-header">
<strong>全球分布</strong>
<small>
{countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"}
{mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""}
</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="地图指标">
{MAP_METRICS.map((item) => (
<button
aria-checked={mapMetric === item.key}
className={mapMetric === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setMapMetric(item.key)}
role="radio"
title={metricTooltip(item.key)}
type="button"
>
{item.label}
</button>
))}
</div>
</div>
</div>
<div className="sbi-rg-map-body">
{mapIndex.entries.size ? (
<EChart className="sbi-rg-map-chart" option={mapOption} />
) : (
<div className="sbi-empty">
<strong>暂无国家数据</strong>
<span>当前区间没有国家粒度数据或国家编码未能匹配到世界地图</span>
</div>
)}
</div>
</section>
<div className="sbi-rg-grid">
{regionRows.map((row) => {
const key = `${row.app_code}:${row.region_id}`;
const appTotal = appRechargeTotals.get(row.app_code);
const share = appTotal && !isBlank(row.recharge_usd_minor) ? Number(row.recharge_usd_minor) / appTotal : null;
return (
<section className="sbi-card sbi-rg-region-card" key={key}>
<div className="sbi-rg-region-head">
<span className="sbi-rg-region-name" title={row.region_name}>
{row.region_name}
</span>
{row.restricted ? (
<span className="sbi-badge" title="数据按你的负责范围裁剪">
范围裁剪
</span>
) : null}
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
</span>
</div>
<div className="sbi-rg-region-money">
<small className="sbi-metric-hint" title={metricTooltip("recharge_usd_minor")}>
{metricLabel("recharge_usd_minor")}
</small>
<strong>{formatMoneyMinor(row.recharge_usd_minor)}</strong>
</div>
<div className="sbi-rg-region-metrics">
{CARD_METRICS.map((metric) => (
<div className="sbi-rg-region-metric" key={metric}>
<small className="sbi-metric-hint" title={metricTooltip(metric)}>
{metricLabel(metric)}
</small>
<span>{formatMetric(metric, row[metric])}</span>
</div>
))}
</div>
<div className="sbi-rg-share">
<span className="sbi-rg-share-head">
<span> {row.app_name} 总充值</span>
<span>{share === null ? "--" : formatRatioPercent(share)}</span>
</span>
<span className="sbi-rg-share-track">
<span
className="sbi-rg-share-fill"
style={{ width: share === null ? 0 : `${Math.min(100, Math.max(0, share * 100))}%` }}
/>
</span>
</div>
</section>
);
})}
</div>
<section className="sbi-card sbi-rg-table-card">
<div className="sbi-card-header">
<strong>区域下钻</strong>
<small>点击区域行展开国家明细 · 按充值降序</small>
</div>
<div className="sbi-table-scroll">
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">地区 / 国家</th>
<th className="is-left">App</th>
{TABLE_METRICS.map((metric) => (
<th key={metric}>
<span className="sbi-metric-hint" title={metricTooltip(metric)}>
{metricLabel(metric)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
<tr className="is-total">
<td className="is-left">全部区域</td>
<td className="is-left">{formatCount(appCount)} App</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, regionSummary[metric])}</td>
))}
</tr>
{regionRows.map((row) => {
const key = `${row.app_code}:${row.region_id}`;
const isOpen = expandedRegions.has(key);
const children = isOpen ? countriesByRegion.get(key) || [] : [];
return (
<Fragment key={key}>
<tr aria-expanded={isOpen} className="sbi-rg-row" onClick={() => toggleRegion(key)}>
<td className="is-left">
<span className="sbi-rg-region-cell">
<span aria-hidden="true" className={isOpen ? "sbi-rg-caret is-open" : "sbi-rg-caret"}>
</span>
{row.region_name}
{row.restricted ? (
<span className="sbi-badge" title="数据按你的负责范围裁剪">
范围裁剪
</span>
) : null}
</span>
</td>
<td className="is-left">
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
</span>
</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, row[metric])}</td>
))}
</tr>
{isOpen && !children.length ? (
<tr className="sbi-rg-child">
<td className="is-left" colSpan={2 + TABLE_METRICS.length}>
<span className="sbi-rg-child-name">该区域暂无国家明细</span>
</td>
</tr>
) : null}
{children.map((child) => (
<tr className="sbi-rg-child" key={`${key}:${child.country_code || child.country_name}`}>
<td className="is-left">
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
</td>
<td className="is-left sbi-rg-child-app">{child.app_name}</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, child[metric])}</td>
))}
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</>
);
}
function RegionsSkeleton() {
return (
<>
<div className="sbi-rg-summary" aria-busy="true">
{[0, 1, 2].map((index) => (
<section className="sbi-card sbi-rg-summary-card" key={index}>
<span className="sbi-skeleton" style={{ height: 12, width: 90 }} />
<span className="sbi-skeleton" style={{ height: 26, width: 140 }} />
<span className="sbi-skeleton" style={{ height: 11, width: 170 }} />
</section>
))}
</div>
<section className="sbi-card sbi-rg-map-card" aria-busy="true">
<div className="sbi-card-header">
<span className="sbi-skeleton" style={{ height: 14, width: 120 }} />
</div>
<div className="sbi-rg-map-body">
<span className="sbi-skeleton" style={{ display: "block", height: "100%", width: "100%" }} />
</div>
</section>
<div className="sbi-rg-grid" aria-busy="true">
{[0, 1, 2].map((index) => (
<section className="sbi-card sbi-rg-region-card" key={index}>
<span className="sbi-skeleton" style={{ height: 14, width: 150 }} />
<span className="sbi-skeleton" style={{ height: 24, width: 130 }} />
<span className="sbi-skeleton" style={{ height: 34, width: "100%" }} />
</section>
))}
</div>
</>
);
}

View File

@ -0,0 +1,400 @@
// RetentionView D1/D7/D30 ×
// / AppApp/ SocialBiApp.jsx useSocialBi()
import { useMemo, useState } from "react";
import { useSocialBi } from "../SocialBiApp.jsx";
import { EChart } from "../../charts/EChart.jsx";
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js";
import { ALL, bucketDay } from "../state.js";
import "./retention-view.css";
// canvas CSS social-v2.css token
const CHART_ACCENT = "#2557d6"; // var(--sbi-accent)
const CHART_ACCENT_2 = "#0e7490"; // var(--sbi-accent-2)
const CHART_GRID_LINE = "#eef3f9";
const CHART_TEXT = "#5b7089";
const CHART_GRID = { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 };
const CHART_LEGEND = { itemHeight: 9, itemWidth: 14, textStyle: { color: CHART_TEXT, fontSize: 12 }, top: 0 };
const CHART_TOOLTIP = { backgroundColor: "#ffffff", borderColor: CHART_GRID_LINE, textStyle: { color: CHART_TEXT }, trigger: "axis" };
const RETENTION_SERIES = [
{ color: CHART_ACCENT, key: "d1_retention_rate" },
{ color: CHART_ACCENT_2, key: "d7_retention_rate" },
{ color: "#7c3aed", key: "d30_retention_rate" }
];
const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]);
const TABLE_METRIC_KEYS = [
"new_users",
"active_users",
"d1_retention_rate",
"d7_retention_rate",
"d30_retention_rate",
"paid_conversion_rate"
];
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
// bucketDay key
function bucketAggregates(rows, granularity) {
const map = new Map();
rows.forEach((row) => {
const bucket = bucketDay(row.stat_day, granularity);
const list = map.get(bucket);
if (list) {
list.push(row);
} else {
map.set(bucket, [row]);
}
});
const buckets = [...map.keys()].sort();
return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets };
}
function categoryAxis(buckets) {
return {
axisLabel: { color: CHART_TEXT },
axisLine: { lineStyle: { color: CHART_GRID_LINE } },
axisTick: { show: false },
data: buckets,
type: "category"
};
}
// 0.85 0.35 null "--"
function renderMetricCell(key, value) {
if (!HEAT_KEYS.has(key)) {
return <td key={key}>{formatMetric(key, value)}</td>;
}
if (isBlank(value)) {
return <td key={key}>--</td>;
}
const depth = Math.min(0.85, Number(value) * 2);
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
if (depth > 0.35) {
style.color = "#ffffff";
}
return (
<td key={key} style={style}>
{formatRatioPercent(value)}
</td>
);
}
export function RetentionView() {
const { derived, filters, isLoading } = useSocialBi();
const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
const [trendApp, setTrendApp] = useState(ALL);
const [tableDim, setTableDim] = useState("app");
const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日";
// App 退 state
const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
// //
const dailyRows = regionScoped ? regionDaily : appDaily;
// 1) App
const summary = useMemo(() => {
const rows = regionScoped ? regionTotals : appTotals;
return rows.length ? aggregateMetricMaps(rows) : null;
}, [appTotals, regionScoped, regionTotals]);
// 2) /× 线/null 线
const comboOption = useMemo(() => {
const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity);
if (!buckets.length) {
return null;
}
return {
grid: { ...CHART_GRID },
legend: { ...CHART_LEGEND },
series: [
{
barMaxWidth: 26,
data: aggregates.map((row) => metricChartValue("new_users", row.new_users)),
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
name: metricLabel("new_users"),
tooltip: { valueFormatter: (value) => formatCount(value) },
type: "bar"
},
{
connectNulls: false,
data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)),
itemStyle: { color: CHART_ACCENT_2 },
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
name: metricLabel("d1_retention_rate"),
symbol: "circle",
symbolSize: 6,
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
type: "line",
yAxisIndex: 1
}
],
tooltip: { ...CHART_TOOLTIP, axisPointer: { type: "shadow" } },
xAxis: categoryAxis(buckets),
yAxis: [
{
axisLabel: { color: CHART_TEXT },
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
type: "value"
},
{
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
splitLine: { show: false },
type: "value"
}
]
};
}, [dailyRows, filters.granularity]);
// 3) D1/D7/D30 App
const trendOption = useMemo(() => {
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
const { aggregates, buckets } = bucketAggregates(rows, filters.granularity);
if (!buckets.length) {
return null;
}
return {
grid: { ...CHART_GRID },
legend: { ...CHART_LEGEND },
series: RETENTION_SERIES.map(({ color, key }) => ({
connectNulls: false,
data: aggregates.map((row) => metricChartValue(key, row[key])),
itemStyle: { color },
lineStyle: { color, width: 2.5 },
name: metricLabel(key),
symbol: "circle",
symbolSize: 5,
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
type: "line"
})),
tooltip: { ...CHART_TOOLTIP },
xAxis: categoryAxis(buckets),
yAxis: {
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
type: "value"
}
};
}, [activeTrendApp, dailyRows, filters.granularity]);
// 4) App / " App" App
const tableSource = useMemo(() => {
if (tableDim === "region") {
return regionTotals;
}
if (!regionScoped) {
return appTotals;
}
const byApp = new Map();
regionTotals.forEach((row) => {
const list = byApp.get(row.app_code) || [];
list.push(row);
byApp.set(row.app_code, list);
});
return [...byApp.entries()].map(([appCode, rows]) => ({
app_code: appCode,
app_name: rows[0].app_name,
...aggregateMetricMaps(rows)
}));
}, [appTotals, regionScoped, regionTotals, tableDim]);
const tableRows = useMemo(() => {
return tableSource
.map((row) => ({
key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
label: tableDim === "region" ? `${row.app_name}·${row.region_name}` : row.app_name,
row
}))
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
}, [tableDim, tableSource]);
const tableTotal = useMemo(() => {
return tableSource.length ? aggregateMetricMaps(tableSource) : null;
}, [tableSource]);
const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0;
if (isLoading && !hasRows) {
return (
<div className="sbi-rt-view">
<div className="sbi-rt-summary">
{RETENTION_SERIES.map(({ key }) => (
<section className="sbi-card sbi-rt-summary-card" key={key}>
<span className="sbi-skeleton sbi-rt-skeleton-line" />
<span className="sbi-skeleton sbi-rt-skeleton-value" />
</section>
))}
</div>
<section className="sbi-card">
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
</section>
<section className="sbi-card">
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
</section>
<section className="sbi-card">
<span className="sbi-skeleton sbi-rt-skeleton-table" />
</section>
</div>
);
}
if (!hasRows) {
return (
<div className="sbi-rt-view">
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无留存数据</strong>
<span>当前筛选没有返回任何 App 数据试试调整日期区间或放宽 App/区域筛选</span>
</div>
</section>
</div>
);
}
return (
<div className="sbi-rt-view">
<div className="sbi-rt-summary">
{RETENTION_SERIES.map(({ key }) => {
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
return (
<section className="sbi-card sbi-rt-summary-card" key={key}>
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
整体{metricLabel(key)}
</span>
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
<span className="sbi-rt-summary-note">
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)}`}
</span>
</section>
);
})}
</div>
<section className="sbi-card">
<div className="sbi-card-header">
<strong>新增与次日留存</strong>
<small>=新增左轴· 线=次日留存率右轴· {granularityLabel}分桶</small>
</div>
{comboOption ? (
<EChart className="sbi-rt-chart-combo" option={comboOption} />
) : (
<div className="sbi-empty">
<strong>暂无按日数据</strong>
<span>当前筛选没有返回按日序列无法绘制新增与留存趋势</span>
</div>
)}
</section>
<section className="sbi-card">
<div className="sbi-card-header">
<strong>留存趋势</strong>
<small>D1 / D7 / D30 · {granularityLabel}分桶</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
<button
aria-checked={activeTrendApp === ALL}
className={activeTrendApp === ALL ? "is-active" : ""}
onClick={() => setTrendApp(ALL)}
role="radio"
type="button"
>
汇总
</button>
{appTotals.map((row) => (
<button
aria-checked={activeTrendApp === row.app_code}
className={activeTrendApp === row.app_code ? "is-active" : ""}
key={row.app_code}
onClick={() => setTrendApp(row.app_code)}
role="radio"
type="button"
>
{row.app_name}
</button>
))}
</div>
</div>
</div>
{trendOption ? (
<EChart className="sbi-rt-chart-trend" option={trendOption} />
) : (
<div className="sbi-empty">
<strong>暂无留存趋势数据</strong>
<span>{activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}</span>
</div>
)}
</section>
<section className="sbi-card sbi-rt-table-card">
<div className="sbi-card-header">
<strong>留存对比</strong>
<small>D1/D7/D30 底色越深留存越高</small>
<div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
<button
aria-checked={tableDim === "app"}
className={tableDim === "app" ? "is-active" : ""}
onClick={() => setTableDim("app")}
role="radio"
type="button"
>
App
</button>
<button
aria-checked={tableDim === "region"}
className={tableDim === "region" ? "is-active" : ""}
onClick={() => setTableDim("region")}
role="radio"
type="button"
>
按区域
</button>
</div>
</div>
</div>
{tableRows.length ? (
<div className="sbi-table-scroll">
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">对象</th>
{TABLE_METRIC_KEYS.map((key) => (
<th key={key}>
<span className="sbi-metric-hint" title={metricTooltip(key)}>
{metricLabel(key)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
<tr className="is-total">
<td className="is-left">合计</td>
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
</tr>
{tableRows.map((item) => (
<tr key={item.key}>
<td className="is-left">{item.label}</td>
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="sbi-empty">
<strong>暂无对比数据</strong>
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
</div>
)}
</section>
<p className="sbi-rt-footnote">
留存按注册日 cohort 计算观察期未到的日期显示 "--"hyapp App 的按日留存暂不提供仅整段区间口径
</p>
</div>
);
}

View File

@ -0,0 +1,601 @@
// TeamKpiViewkpi_view_all " KPI"
// SocialBiApp.jsx useSocialBi() USD 0-1 null "--"
import { useMemo, useState } from "react";
import Button from "@mui/material/Button";
import { useSocialBi } from "../SocialBiApp.jsx";
import { replaceSocialBiKpiTargets } from "../../api.js";
import { appColor } from "../metrics.js";
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./team-kpi-view.css";
const DAY_MS = 86400000;
const DIMENSIONS = [
{ key: "person", label: "按人员" },
{ key: "team", label: "按部门" }
];
const SORTS = [
{ key: "rate", label: "按达成率" },
{ key: "month", label: "按当月充值" }
];
// 0
function remainingDaysOf(monthEndMs) {
if (isBlank(monthEndMs)) {
return 0;
}
return Math.max(0, Math.ceil((Number(monthEndMs) - Date.now()) / DAY_MS));
}
// USD - null 0
function gapMinorOf(targetMinor, monthMinor) {
if (isBlank(targetMinor) || Number(targetMinor) <= 0 || isBlank(monthMinor)) {
return null;
}
return Number(targetMinor) - Number(monthMinor);
}
// "" / "" "--"
function dailyNeedText(gapMinor, remainingDays) {
if (gapMinor === null) {
return "--";
}
if (gapMinor <= 0) {
return "已达成";
}
if (!remainingDays) {
return "--";
}
return formatMoneyMinor(Math.round(gapMinor / remainingDays));
}
// null 0
function addNullable(sum, value) {
if (isBlank(value)) {
return sum;
}
return (sum ?? 0) + Number(value);
}
function numberOr(value, fallback) {
return isBlank(value) ? fallback : Number(value);
}
//
function compareRows(left, right, sortKey) {
if (sortKey === "rate") {
const diff = numberOr(right.rate, -1) - numberOr(left.rate, -1);
if (diff) {
return diff;
}
}
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
}
function operatorLabelOf(item) {
return item.operator_name || item.operator_account || `#${item.operator_user_id}`;
}
function regionLabelOf(item) {
return item.region_name || item.region_code || "全部区域";
}
function usdInputText(minor) {
if (isBlank(minor)) {
return "0";
}
return String(Number(minor) / 100);
}
// USD 0NaN/ null
function usdInputToMinor(text) {
const numeric = Number(String(text).trim());
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return Math.round(numeric * 100);
}
export function TeamKpiView() {
const { appCodes, isLoading, kpi, master, range, refresh } = useSocialBi();
const [dimension, setDimension] = useState("person");
const [sortKey, setSortKey] = useState("rate");
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const items = useMemo(() => kpi?.items || [], [kpi]);
const personRows = useMemo(() => {
const rows = items.map((item, index) => ({
item,
key: `${item.operator_user_id}-${item.app_code}-${item.region_id}-${index}`,
monthMinor: item.month_recharge_usd_minor,
rate: item.month_attainment_rate
}));
rows.sort((left, right) => compareRows(left, right, sortKey));
return rows;
}, [items, sortKey]);
const teamRows = useMemo(() => {
const groups = new Map();
items.forEach((item) => {
const teamName = item.team || "未分组";
let group = groups.get(teamName);
if (!group) {
group = { members: new Set(), monthMinor: null, rangeMinor: null, targetMinor: null, team: teamName };
groups.set(teamName, group);
}
group.members.add(item.operator_user_id);
group.rangeMinor = addNullable(group.rangeMinor, item.range_recharge_usd_minor);
group.monthMinor = addNullable(group.monthMinor, item.month_recharge_usd_minor);
group.targetMinor = addNullable(group.targetMinor, item.month_target_usd_minor);
});
const rows = [...groups.values()].map((group) => ({
memberCount: group.members.size,
monthMinor: group.monthMinor,
rangeMinor: group.rangeMinor,
rate: group.targetMinor && group.monthMinor !== null ? group.monthMinor / group.targetMinor : null,
targetMinor: group.targetMinor,
team: group.team
}));
rows.sort((left, right) => compareRows(left, right, sortKey));
return rows;
}, [items, sortKey]);
if (!items.length) {
return isLoading ? <KpiSkeleton /> : (
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无 KPI 数据</strong>
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
</div>
</section>
);
}
const summary = kpi.summary || {};
const canViewAll = master?.permissions?.kpi_view_all !== false;
const canManage = Boolean(master?.permissions?.kpi_manage);
const remainingDays = remainingDaysOf(kpi.month_end_ms);
const heroRate = summary.month_attainment_rate;
const attainText = isBlank(heroRate) ? "未设目标" : formatRatioPercent(heroRate);
const ringPct = isBlank(heroRate) ? 0 : Math.max(0, Math.min(100, Number(heroRate) * 100));
const heroGap = gapMinorOf(summary.month_target_usd_minor, summary.month_recharge_usd_minor);
const heroGapText = heroGap === null ? "--" : heroGap <= 0 ? "已达成" : formatMoneyMinor(heroGap);
const heroDailyNeed =
heroGap !== null && heroGap > 0 && remainingDays > 0 ? formatMoneyMinor(Math.round(heroGap / remainingDays)) : "--";
return (
<div className="sbi-kpi-view">
<section className="sbi-kpi-hero">
<article className="sbi-card sbi-kpi-hero-main">
<div className="sbi-card-header">
<strong>{canViewAll ? "团队 KPI" : "我的 KPI"}</strong>
<small>本月充值目标进度 · {kpi.period_month}</small>
{!canViewAll ? <span className="sbi-badge">仅展示我负责的范围</span> : null}
</div>
<div className="sbi-kpi-hero-body">
<div
aria-label={`本月达成率 ${attainText}`}
className="sbi-kpi-ring"
role="img"
style={{ "--sbi-kpi-pct": String(ringPct) }}
>
<div className="sbi-kpi-ring-hole">
<strong className={isBlank(heroRate) ? "is-muted" : undefined}>{attainText}</strong>
<small>本月达成率</small>
</div>
</div>
<dl className="sbi-kpi-hero-stats">
<div>
<dt>当月充值</dt>
<dd>{formatMoneyMinor(summary.month_recharge_usd_minor)}</dd>
</div>
<div>
<dt>月目标</dt>
<dd>{formatMoneyMinor(summary.month_target_usd_minor)}</dd>
</div>
<div>
<dt className="sbi-metric-hint" title="月目标 - 当月充值">缺口</dt>
<dd>{heroGapText}</dd>
</div>
</dl>
</div>
</article>
<article className="sbi-card sbi-kpi-mini">
<span className="sbi-kpi-mini-label">剩余天数</span>
<strong className="sbi-kpi-mini-value">{remainingDays} </strong>
<small className="sbi-kpi-mini-note">{kpi.period_month}</small>
</article>
<article className="sbi-card sbi-kpi-mini">
<span className="sbi-kpi-mini-label sbi-metric-hint" title="缺口 / 剩余天数">需日均</span>
<strong className="sbi-kpi-mini-value">{heroDailyNeed}</strong>
<small className="sbi-kpi-mini-note">达成月目标每日还需充值</small>
</article>
<article className="sbi-card sbi-kpi-mini">
<span className="sbi-kpi-mini-label">区间充值</span>
<strong className="sbi-kpi-mini-value">{formatMoneyMinor(summary.range_recharge_usd_minor)}</strong>
<small className="sbi-kpi-mini-note">{rangeLabel(range)}</small>
</article>
</section>
<section className="sbi-card sbi-kpi-board">
<div className="sbi-card-header">
<strong>绩效排行</strong>
<small>
{kpi.period_month} · {formatCount(summary.operator_count)} 名运营
</small>
<div className="sbi-card-toolbar">
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
{DIMENSIONS.map((option) => (
<button
aria-checked={dimension === option.key}
className={dimension === option.key ? "is-active" : ""}
key={option.key}
onClick={() => setDimension(option.key)}
role="radio"
type="button"
>
{option.label}
</button>
))}
</div>
<div aria-label="排序方式" className="sbi-seg" role="radiogroup">
{SORTS.map((option) => (
<button
aria-checked={sortKey === option.key}
className={sortKey === option.key ? "is-active" : ""}
key={option.key}
onClick={() => setSortKey(option.key)}
role="radio"
type="button"
>
{option.label}
</button>
))}
</div>
{canManage ? (
<Button onClick={() => setIsDrawerOpen(true)} size="small" variant="outlined">
配置目标
</Button>
) : null}
</div>
</div>
<div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? (
<PersonTable appCodes={appCodes} remainingDays={remainingDays} rows={personRows} />
) : (
<TeamTable rows={teamRows} />
)}
</div>
</section>
{isDrawerOpen ? (
<KpiTargetDrawer
appCodes={appCodes}
kpi={kpi}
onClose={() => setIsDrawerOpen(false)}
onSaved={() => {
setIsDrawerOpen(false);
refresh();
}}
/>
) : null}
</div>
);
}
function KpiSkeleton() {
return (
<div aria-busy="true" className="sbi-kpi-view">
<section className="sbi-kpi-hero">
<article className="sbi-card sbi-kpi-hero-loading">
<span className="sbi-skeleton sbi-kpi-skeleton-ring" />
<div className="sbi-kpi-skeleton-lines">
<span className="sbi-skeleton" style={{ height: 16, width: "72%" }} />
<span className="sbi-skeleton" style={{ height: 16, width: "56%" }} />
<span className="sbi-skeleton" style={{ height: 16, width: "64%" }} />
</div>
</article>
{[0, 1, 2].map((index) => (
<article className="sbi-card sbi-kpi-mini" key={index}>
<span className="sbi-skeleton" style={{ height: 12, width: 64 }} />
<span className="sbi-skeleton" style={{ height: 24, width: 112 }} />
<span className="sbi-skeleton" style={{ height: 10, width: 88 }} />
</article>
))}
</section>
<section className="sbi-card">
<div className="sbi-kpi-table-skeleton">
{[0, 1, 2, 3, 4].map((index) => (
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
))}
</div>
</section>
</div>
);
}
function RankBadge({ rank }) {
const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : "";
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
}
// + 100% null ""
function AttainmentBar({ rate }) {
if (isBlank(rate)) {
return <span className="sbi-kpi-no-target">未设目标</span>;
}
const numeric = Number(rate);
const pct = Math.max(0, Math.min(100, numeric * 100));
return (
<span className="sbi-kpi-attain">
<span aria-hidden="true" className="sbi-kpi-bar">
<span className={numeric >= 1 ? "sbi-kpi-bar-fill is-done" : "sbi-kpi-bar-fill"} style={{ width: `${pct}%` }} />
</span>
<span className="sbi-kpi-attain-value">{formatRatioPercent(rate)}</span>
</span>
);
}
function PersonTable({ appCodes, remainingDays, rows }) {
return (
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">名次</th>
<th className="is-left">运营人员</th>
<th className="is-left">App</th>
<th className="is-left">区域</th>
<th>
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计USD">区间充值</span>
</th>
<th>
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计USD">当月充值</span>
</th>
<th>
<span className="sbi-metric-hint" title="本月充值目标USD由 KPI 管理员配置">月目标</span>
</th>
<th>
<span className="sbi-metric-hint" title="当月充值 / 月目标">达成率</span>
</th>
<th>
<span className="sbi-metric-hint" title="缺口 / 剩余天数,达成月目标每日还需充值">需日均</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const item = row.item;
const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor);
return (
<tr key={row.key}>
<td className="is-left">
<RankBadge rank={index + 1} />
</td>
<td className="is-left">
<div className="sbi-kpi-person">
<span className="sbi-kpi-person-top">
<strong>{operatorLabelOf(item)}</strong>
{item.data_error ? (
<span className="sbi-badge is-warn" title={item.data_error}>
数据异常
</span>
) : null}
</span>
<small>{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}</small>
</div>
</td>
<td className="is-left">
<span className="sbi-kpi-app">
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} />
{item.app_name || item.app_code}
</span>
</td>
<td className="is-left">{regionLabelOf(item)}</td>
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
<td>{formatMoneyMinor(item.month_target_usd_minor)}</td>
<td>
<AttainmentBar rate={item.month_attainment_rate} />
</td>
<td>{dailyNeedText(gap, remainingDays)}</td>
</tr>
);
})}
</tbody>
</table>
);
}
function TeamTable({ rows }) {
return (
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">名次</th>
<th className="is-left">部门</th>
<th>
<span className="sbi-metric-hint" title="部门内去重运营人数">人数</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门内区间充值合计USD">区间充值</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门内本月充值合计USD">当月充值</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门内月目标合计USD">月目标</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门当月充值合计 / 月目标合计">达成率</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.team}>
<td className="is-left">
<RankBadge rank={index + 1} />
</td>
<td className="is-left">{row.team}</td>
<td>{formatCount(row.memberCount)}</td>
<td>{formatMoneyMinor(row.rangeMinor)}</td>
<td>{formatMoneyMinor(row.monthMinor)}</td>
<td>{formatMoneyMinor(row.targetMinor)}</td>
<td>
<AttainmentBar rate={row.rate} />
</td>
</tr>
))}
</tbody>
</table>
);
}
// × App × /
// PUT /admin/databi/social/kpi-targetsreplaceSocialBiKpiTargets/ 0
function KpiTargetDrawer({ appCodes, kpi, onClose, onSaved }) {
const [drafts, setDrafts] = useState(() =>
(kpi.items || []).map((item) => ({
appCode: item.app_code,
appName: item.app_name || item.app_code,
dailyText: usdInputText(item.daily_target_usd_minor),
monthText: usdInputText(item.month_target_usd_minor),
operatorAccount: item.operator_account || "",
operatorLabel: operatorLabelOf(item),
regionId: Number(item.region_id ?? 0),
regionName: regionLabelOf(item),
userId: item.operator_user_id
}))
);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const hasInvalid = drafts.some(
(draft) => usdInputToMinor(draft.monthText) === null || usdInputToMinor(draft.dailyText) === null
);
const updateDraft = (index, key, value) => {
setDrafts((current) => current.map((draft, draftIndex) => (draftIndex === index ? { ...draft, [key]: value } : draft)));
};
const handleSave = async () => {
const payload = drafts.map((draft) => ({
appCode: draft.appCode,
dailyTargetUsdMinor: usdInputToMinor(draft.dailyText),
periodMonth: kpi.period_month,
regionId: draft.regionId,
targetUsdMinor: usdInputToMinor(draft.monthText),
userId: draft.userId
}));
setIsSaving(true);
setSaveError("");
try {
await replaceSocialBiKpiTargets(payload);
onSaved();
} catch (error) {
setSaveError(error?.message || "保存失败,请稍后重试");
setIsSaving(false);
}
};
return (
<div className="sbi-kpi-drawer-mask" onClick={onClose} role="presentation">
<aside aria-label="配置 KPI 目标" className="sbi-kpi-drawer" onClick={(event) => event.stopPropagation()} role="dialog">
<header className="sbi-kpi-drawer-header">
<div className="sbi-kpi-drawer-title">
<strong>配置充值 KPI 目标</strong>
<small>{kpi.period_month} · 运营人员 × App × 区域 · /日目标填 0 表示清除</small>
</div>
<button aria-label="关闭" className="sbi-kpi-drawer-close" onClick={onClose} type="button">
×
</button>
</header>
<div className="sbi-kpi-drawer-body">
{drafts.length ? (
<div className="sbi-table-scroll">
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">运营人员</th>
<th className="is-left">App</th>
<th className="is-left">区域</th>
<th>当月目标 ($)</th>
<th>当日目标 ($)</th>
</tr>
</thead>
<tbody>
{drafts.map((draft, index) => (
<tr key={`${draft.userId}-${draft.appCode}-${draft.regionId}-${index}`}>
<td className="is-left">
<div className="sbi-kpi-person">
<span className="sbi-kpi-person-top">
<strong>{draft.operatorLabel}</strong>
</span>
{draft.operatorAccount ? <small>{draft.operatorAccount}</small> : null}
</div>
</td>
<td className="is-left">
<span className="sbi-kpi-app">
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
{draft.appName}
</span>
</td>
<td className="is-left">{draft.regionName}</td>
<td>
<input
aria-label={`${draft.operatorLabel} ${draft.appName} 当月目标(美元)`}
className={usdInputToMinor(draft.monthText) === null ? "sbi-kpi-input is-invalid" : "sbi-kpi-input"}
inputMode="decimal"
min="0"
onChange={(event) => updateDraft(index, "monthText", event.target.value)}
step="0.01"
type="number"
value={draft.monthText}
/>
</td>
<td>
<input
aria-label={`${draft.operatorLabel} ${draft.appName} 当日目标(美元)`}
className={usdInputToMinor(draft.dailyText) === null ? "sbi-kpi-input is-invalid" : "sbi-kpi-input"}
inputMode="decimal"
min="0"
onChange={(event) => updateDraft(index, "dailyText", event.target.value)}
step="0.01"
type="number"
value={draft.dailyText}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="sbi-empty">
<strong>暂无可配置的目标行</strong>
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
</div>
)}
</div>
<footer className="sbi-kpi-drawer-footer">
{saveError || hasInvalid ? (
<span className="sbi-kpi-drawer-error" role="alert">
{saveError || "目标金额需为非负数字"}
</span>
) : null}
<Button disabled={isSaving} onClick={onClose} variant="text">
取消
</Button>
<Button disabled={hasInvalid || isSaving || !drafts.length} onClick={handleSave} variant="contained">
{isSaving ? "保存中…" : "保存目标"}
</Button>
</footer>
</aside>
</div>
);
}

View File

@ -0,0 +1,100 @@
/* 数据明细视图DataTableView专属样式类名前缀 sbi-dt-
颜色/圆角只使用 social-v2.css token 与其既有中性色#f1f5fa / #f7fafd / rgba(16,34,56,) */
.sbi-dt-tools {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px 18px 14px;
}
.sbi-dt-groups {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sbi-dt-groups-label {
color: var(--sbi-text-3);
font-size: 12px;
}
.sbi-dt-export {
margin-left: auto;
}
/* 表格纵向滚动容器:限制高度让两级表头的 sticky 生效,底部跟随卡片圆角。 */
.sbi-dt-scroll {
max-height: 72vh;
border-top: 1px solid var(--sbi-border);
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
}
/* 第一行:列组表头(浅灰底、居中、固定高度,供第二行 sticky 偏移用)。 */
.sbi-dt-table th.sbi-dt-group-th {
box-sizing: border-box;
height: 34px;
padding: 0 12px;
background: #f1f5fa;
color: var(--sbi-text-3);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
text-align: center;
}
/* 第二行指标表头sticky 吸附在列组行下方,可点击排序。 */
.sbi-dt-table th.sbi-dt-metric-th {
top: 34px;
cursor: pointer;
user-select: none;
}
.sbi-dt-table th.sbi-dt-metric-th:hover,
.sbi-dt-table th.sbi-dt-metric-th.is-sorted {
color: var(--sbi-accent);
}
.sbi-dt-sort {
margin-left: 3px;
font-size: 11px;
}
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
.sbi-dt-table .sbi-dt-sticky {
position: sticky;
left: 0;
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
}
.sbi-dt-table td.sbi-dt-sticky {
z-index: 1;
background: var(--sbi-card);
}
.sbi-dt-table th.sbi-dt-sticky {
z-index: 4;
}
/* 截断提示(表尾整行)。 */
.sbi-dt-note td {
padding: 10px 12px;
background: #f7fafd;
color: var(--sbi-text-3);
font-size: 12px;
text-align: center;
white-space: normal;
}
/* 无数据时的加载骨架。 */
.sbi-dt-skeleton {
display: grid;
gap: 10px;
padding: 6px 18px 20px;
}
.sbi-dt-skeleton .sbi-skeleton {
height: 14px;
}

View File

@ -0,0 +1,195 @@
/* 经营概览视图专属样式:只消费 social-v2.css 的 token类名前缀 sbi-ov-。 */
.sbi-ov {
display: flex;
flex-direction: column;
gap: 16px;
}
.sbi-ov-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* ---------- Hero KPI ---------- */
.sbi-ov-hero {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.sbi-ov-hero-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px 10px;
}
.sbi-ov-hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sbi-ov-hero-label {
overflow: hidden;
color: var(--sbi-text-2);
font-size: 12.5px;
font-weight: 640;
white-space: nowrap;
text-overflow: ellipsis;
}
.sbi-ov-hero-value {
color: var(--sbi-text);
font-size: 22px;
font-weight: 800;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.sbi-ov-hero-spark {
height: 38px;
margin-top: auto;
}
.sbi-ov-spark-chart {
width: 100%;
height: 38px;
}
/* ---------- 卡片主体 / 图表容器 ---------- */
.sbi-ov-card-body {
padding: 12px 18px 16px;
}
.sbi-ov-trend-chart {
height: 300px;
}
.sbi-ov-channel-chart {
height: 240px;
}
.sbi-ov-duo {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* ---------- App 对比 / 区域 Top5 横向条 ---------- */
.sbi-ov-compare {
display: grid;
gap: 12px;
}
.sbi-ov-compare-row {
display: grid;
grid-template-columns: minmax(110px, 150px) minmax(0, 1fr) max-content max-content;
align-items: center;
gap: 10px;
}
.sbi-ov-compare-name {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
overflow: hidden;
color: var(--sbi-text);
font-size: 13px;
font-weight: 640;
white-space: nowrap;
text-overflow: ellipsis;
}
.sbi-ov-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.sbi-ov-bar-track {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: #eef3f9;
}
.sbi-ov-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--sbi-accent-grad);
}
.sbi-ov-num {
min-width: 84px;
color: var(--sbi-text);
font-size: 13px;
font-weight: 700;
text-align: right;
font-variant-numeric: tabular-nums;
}
.sbi-ov-link {
padding: 2px 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--sbi-accent);
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.sbi-ov-link:hover {
background: rgba(37, 87, 214, 0.08);
}
.sbi-ov-top {
display: grid;
gap: 12px;
}
.sbi-ov-top-row {
display: grid;
grid-template-columns: minmax(180px, 260px) minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
}
.sbi-ov-top-name {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
font-size: 13px;
font-weight: 640;
}
.sbi-ov-top-name > strong {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* ---------- 骨架 ---------- */
.sbi-ov-skeleton-card {
display: grid;
padding: 16px 18px;
}
@media (max-width: 1280px) {
.sbi-ov-hero {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,222 @@
/* 地区洞察RegionsView专属样式类名前缀 sbi-rg-;颜色/圆角一律取 social-v2.css 的 token。 */
/* ---------- 概要条 ---------- */
.sbi-rg-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sbi-rg-summary-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 18px;
}
.sbi-rg-summary-label {
color: var(--sbi-text-2);
font-size: 12.5px;
font-weight: 640;
}
.sbi-rg-summary-value {
color: var(--sbi-text);
font-size: 24px;
font-weight: 760;
font-variant-numeric: tabular-nums;
}
.sbi-rg-summary-sub {
color: var(--sbi-text-3);
font-size: 12px;
}
/* ---------- 世界地图卡 ---------- */
.sbi-rg-map-card {
display: flex;
height: 380px;
flex-direction: column;
}
.sbi-rg-map-body {
flex: 1;
min-height: 0;
padding: 6px 12px 12px;
}
.sbi-rg-map-body .sbi-empty {
height: 100%;
place-content: center;
}
.sbi-rg-map-chart {
width: 100%;
height: 100%;
}
/* ---------- 区域卡片网格 ---------- */
.sbi-rg-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sbi-rg-region-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 18px;
}
.sbi-rg-region-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sbi-rg-region-name {
overflow: hidden;
font-size: 14.5px;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.sbi-rg-app-tag {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--sbi-text-2);
font-size: 12px;
font-weight: 640;
white-space: nowrap;
}
.sbi-rg-region-head .sbi-rg-app-tag {
margin-left: auto;
}
.sbi-rg-app-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.sbi-rg-region-money {
display: flex;
flex-direction: column;
gap: 2px;
}
.sbi-rg-region-money small {
color: var(--sbi-text-3);
font-size: 11.5px;
}
.sbi-rg-region-money strong {
font-size: 22px;
font-weight: 760;
font-variant-numeric: tabular-nums;
}
.sbi-rg-region-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.sbi-rg-region-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.sbi-rg-region-metric small {
color: var(--sbi-text-3);
font-size: 11.5px;
}
.sbi-rg-region-metric span {
font-size: 13.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.sbi-rg-share {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: auto;
}
.sbi-rg-share-head {
display: flex;
justify-content: space-between;
color: var(--sbi-text-3);
font-size: 11.5px;
}
.sbi-rg-share-track {
display: block;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--sbi-bg);
}
.sbi-rg-share-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--sbi-accent-grad);
transition: width 200ms ease;
}
/* ---------- 下钻表 ---------- */
.sbi-rg-table-card .sbi-table-scroll {
max-height: 540px;
margin-top: 10px;
}
.sbi-rg-row {
cursor: pointer;
}
.sbi-rg-region-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sbi-rg-caret {
display: inline-block;
width: 14px;
color: var(--sbi-text-3);
text-align: center;
transition: transform 120ms ease;
}
.sbi-rg-caret.is-open {
transform: rotate(90deg);
}
.sbi-rg-child td {
background: var(--sbi-bg);
color: var(--sbi-text-2);
}
.sbi-rg-child-name {
display: inline-block;
padding-left: 26px;
}
.sbi-rg-child-app {
color: var(--sbi-text-3);
}

View File

@ -0,0 +1,102 @@
/* 留存质量视图RetentionView专属样式公共原语卡片/表格/seg/骨架)见 social-v2.css。 */
.sbi-rt-view {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- 概要卡 ---------- */
.sbi-rt-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sbi-rt-summary-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 18px;
}
.sbi-rt-summary-label {
color: var(--sbi-text-2);
font-size: 12.5px;
font-weight: 640;
}
.sbi-rt-summary-value {
color: var(--sbi-text);
font-size: 26px;
font-weight: 760;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.sbi-rt-summary-note {
color: var(--sbi-text-3);
font-size: 11.5px;
}
/* ---------- 图表卡EChart 容器必须有确定高度) ---------- */
.sbi-rt-chart-combo {
height: 300px;
margin: 4px 12px 12px;
}
.sbi-rt-chart-trend {
height: 280px;
margin: 4px 12px 12px;
}
/* ---------- 热力表 ---------- */
.sbi-rt-table-card .sbi-table-scroll {
margin-top: 10px;
}
/* ---------- 底部口径说明 ---------- */
.sbi-rt-footnote {
margin: 0;
color: var(--sbi-text-3);
font-size: 12px;
line-height: 1.6;
}
/* ---------- 骨架 ---------- */
.sbi-rt-skeleton-line {
display: block;
width: 96px;
height: 14px;
}
.sbi-rt-skeleton-value {
display: block;
width: 150px;
height: 30px;
}
.sbi-rt-skeleton-chart {
display: block;
width: auto;
height: 232px;
margin: 14px 16px 16px;
}
.sbi-rt-skeleton-table {
display: block;
width: auto;
height: 180px;
margin: 14px 16px 16px;
}
@media (max-width: 960px) {
.sbi-rt-summary {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,371 @@
/* 人员绩效视图TeamKpiView专属样式仅消费 social-v2.css token类名前缀 sbi-kpi-
金银铜名次徽标与图表网格色 #eef3f9 为本视图的功能色其余颜色一律走 token */
.sbi-kpi-view {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- Hero1 大卡(环形进度)+ 3 小卡 ---------- */
.sbi-kpi-hero {
display: grid;
grid-template-columns: minmax(0, 1.8fr) repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sbi-kpi-hero-body {
display: flex;
align-items: center;
gap: 28px;
padding: 18px;
}
.sbi-kpi-ring {
--sbi-kpi-pct: 0;
display: grid;
flex: none;
width: 138px;
height: 138px;
place-items: center;
border-radius: 50%;
background: conic-gradient(var(--sbi-accent) calc(var(--sbi-kpi-pct) * 1%), #eef3f9 0);
}
.sbi-kpi-ring-hole {
display: grid;
gap: 2px;
width: 104px;
height: 104px;
place-content: center;
place-items: center;
border-radius: 50%;
background: var(--sbi-card);
}
.sbi-kpi-ring-hole strong {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-ring-hole strong.is-muted {
color: var(--sbi-text-3);
font-size: 14px;
font-weight: 700;
}
.sbi-kpi-ring-hole small {
color: var(--sbi-text-3);
font-size: 11px;
}
.sbi-kpi-hero-stats {
display: grid;
flex: 1;
gap: 11px;
min-width: 0;
margin: 0;
}
.sbi-kpi-hero-stats > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 9px;
border-bottom: 1px dashed var(--sbi-border);
}
.sbi-kpi-hero-stats > div:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.sbi-kpi-hero-stats dt {
color: var(--sbi-text-2);
font-size: 12.5px;
}
.sbi-kpi-hero-stats dd {
margin: 0;
font-size: 17px;
font-weight: 720;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-mini {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 16px 18px;
}
.sbi-kpi-mini-label {
align-self: flex-start;
color: var(--sbi-text-2);
font-size: 12.5px;
font-weight: 640;
}
.sbi-kpi-mini-value {
font-size: 22px;
font-weight: 760;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-mini-note {
color: var(--sbi-text-3);
font-size: 11.5px;
}
/* ---------- 排行榜 ---------- */
.sbi-kpi-board-scroll {
max-height: 620px;
margin-top: 12px;
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
}
.sbi-kpi-rank {
display: inline-grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 50%;
background: #f1f5fa;
color: var(--sbi-text-2);
font-size: 12px;
font-weight: 740;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-rank.is-gold {
background: linear-gradient(135deg, #f6c34c, #d28f0e);
color: #ffffff;
}
.sbi-kpi-rank.is-silver {
background: linear-gradient(135deg, #cfd8e3, #97a4b4);
color: #ffffff;
}
.sbi-kpi-rank.is-bronze {
background: linear-gradient(135deg, #d9a06b, #a56a3c);
color: #ffffff;
}
.sbi-kpi-person {
display: grid;
gap: 2px;
}
.sbi-kpi-person-top {
display: flex;
align-items: center;
gap: 6px;
}
.sbi-kpi-person small {
color: var(--sbi-text-3);
font-size: 11.5px;
}
.sbi-kpi-app {
display: inline-flex;
align-items: center;
gap: 7px;
}
.sbi-kpi-app-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.sbi-kpi-attain {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sbi-kpi-bar {
display: inline-block;
width: 96px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #eef3f9;
}
.sbi-kpi-bar-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--sbi-accent-grad);
}
.sbi-kpi-bar-fill.is-done {
background: var(--sbi-up);
}
.sbi-kpi-attain-value {
min-width: 52px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-no-target {
color: var(--sbi-text-3);
font-size: 12px;
}
/* ---------- 骨架 ---------- */
.sbi-kpi-hero-loading {
display: flex;
align-items: center;
gap: 28px;
padding: 18px;
}
.sbi-kpi-skeleton-ring {
flex: none;
width: 138px;
height: 138px;
border-radius: 50%;
}
.sbi-kpi-skeleton-lines {
display: grid;
flex: 1;
gap: 14px;
}
.sbi-kpi-table-skeleton {
display: grid;
gap: 14px;
padding: 18px;
}
/* ---------- 配置目标抽屉 ---------- */
.sbi-kpi-drawer-mask {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(16, 27, 45, 0.42);
}
.sbi-kpi-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
width: min(760px, 94vw);
border-left: 1px solid var(--sbi-border);
background: var(--sbi-card);
box-shadow: var(--sbi-shadow);
animation: sbi-kpi-slide-in 180ms ease;
}
@keyframes sbi-kpi-slide-in {
from {
transform: translateX(28px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.sbi-kpi-drawer-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--sbi-border);
}
.sbi-kpi-drawer-title {
display: grid;
gap: 3px;
}
.sbi-kpi-drawer-title strong {
font-size: 15px;
}
.sbi-kpi-drawer-title small {
color: var(--sbi-text-3);
font-size: 12px;
}
.sbi-kpi-drawer-close {
display: grid;
width: 30px;
height: 30px;
place-items: center;
margin-left: auto;
border: 1px solid var(--sbi-border);
border-radius: 8px;
background: var(--sbi-card);
color: var(--sbi-text-2);
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.sbi-kpi-drawer-close:hover {
border-color: var(--sbi-accent);
color: var(--sbi-accent);
}
.sbi-kpi-drawer-body {
flex: 1;
overflow: auto;
padding: 0 20px;
}
.sbi-kpi-drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--sbi-border);
}
.sbi-kpi-drawer-error {
margin-right: auto;
color: var(--sbi-down);
font-size: 12.5px;
}
.sbi-kpi-input {
width: 112px;
height: 30px;
padding: 0 8px;
border: 1px solid var(--sbi-border);
border-radius: 7px;
background: var(--sbi-card);
color: var(--sbi-text);
font-size: 13px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.sbi-kpi-input:focus {
border-color: var(--sbi-accent);
outline: none;
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.12);
}
.sbi-kpi-input.is-invalid {
border-color: var(--sbi-down);
}

View File

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

View File

@ -1,773 +0,0 @@
.social-bi-shell {
--social-bi-control-height: 40px;
display: grid;
min-width: 1040px;
min-height: 100vh;
grid-template-columns: 72px minmax(0, 1fr);
background: #f5f7fb;
color: #172033;
}
.social-bi-sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 12px 10px;
border-right: 1px solid #dfe8f2;
background: #ffffff;
}
.social-bi-sidebar-brand {
display: flex;
height: 42px;
align-items: center;
justify-content: center;
padding: 0;
}
.social-bi-sidebar-brand span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 8px;
background: #1688d9;
color: #ffffff;
font-size: 13px;
font-weight: 800;
}
.social-bi-sidebar-brand strong {
display: none;
}
.social-bi-sidebar-nav {
display: grid;
width: 100%;
gap: 8px;
justify-items: center;
}
.social-bi-nav-item {
position: relative;
display: grid;
width: 48px;
height: 44px;
place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #5f6f86;
font: inherit;
transition:
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
color 180ms cubic-bezier(0.2, 0, 0, 1),
transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.social-bi-nav-item svg {
font-size: 20px;
}
.social-bi-nav-item span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.social-bi-nav-item:hover {
border-color: #e0ebf5;
background: #f4f8fc;
color: #34445a;
}
.social-bi-nav-item.is-active,
.social-bi-nav-item.is-active:hover {
border-color: #cfe3f5;
background: #eef7ff;
color: #126fb2;
}
.social-bi-nav-item:active {
transform: translateY(1px);
}
.social-bi-main {
display: grid;
min-width: 0;
align-content: start;
gap: 12px;
padding: 12px 14px 24px;
}
.social-bi-filter-section {
display: flex;
align-items: end;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e4ebf3;
border-radius: 6px;
background: #ffffff;
}
.social-bi-filter-grid {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: end;
gap: 8px;
}
.social-bi-field {
position: relative;
flex: 0 0 220px;
min-width: 0;
}
.social-bi-date-field {
flex-basis: 260px;
}
.social-bi-dropdown--wide {
flex-basis: 260px;
}
.social-bi-field-label {
display: block;
margin-bottom: 3px;
color: #718198;
font-size: 11px;
font-weight: 680;
}
.social-bi-date-options {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
height: var(--social-bi-control-height);
gap: 3px;
padding: 3px;
border: 1px solid #d8e5f0;
border-radius: 6px;
background: #f8fbfe;
}
.social-bi-date-options button {
display: grid;
min-width: 0;
height: 100%;
place-items: center;
border: 0;
border-radius: 5px;
background: transparent;
color: #5f6f86;
font: inherit;
transition:
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
color 180ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
}
.social-bi-date-options button span {
font-size: 12px;
font-weight: 720;
}
.social-bi-date-options button small {
max-width: 100%;
overflow: hidden;
color: #8c9aab;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.social-bi-date-options button:hover,
.social-bi-date-options button.is-active {
background: #ffffff;
color: #126fb2;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.social-bi-dropdown summary {
box-sizing: border-box;
display: flex;
height: var(--social-bi-control-height);
min-height: var(--social-bi-control-height);
align-items: center;
gap: 8px;
padding: 0 30px 0 10px;
border: 1px solid #d8e5f0;
border-radius: 6px;
background: #ffffff;
color: #172033;
list-style: none;
transition:
border-color 180ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 180ms cubic-bezier(0.2, 0, 0, 1);
}
.social-bi-dropdown summary::-webkit-details-marker {
display: none;
}
.social-bi-dropdown summary::after {
position: absolute;
right: 10px;
top: 50%;
color: #8c9aab;
content: "⌄";
font-size: 16px;
transform: translateY(-50%);
}
.social-bi-dropdown summary:hover,
.social-bi-dropdown[open] summary {
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
}
.social-bi-dropdown summary strong {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #172033;
font-size: 13px;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.social-bi-dropdown summary .social-bi-field-label {
flex: 0 0 auto;
margin-bottom: 0;
}
.social-bi-dropdown-menu {
position: absolute;
z-index: 12;
top: calc(100% + 8px);
left: 0;
display: grid;
width: min(320px, 90vw);
max-height: 330px;
gap: 4px;
overflow: auto;
padding: 8px;
border: 1px solid #d8e5f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
}
.social-bi-dropdown--wide .social-bi-dropdown-menu {
width: min(380px, 90vw);
}
.social-bi-dropdown-menu--search input {
box-sizing: border-box;
height: var(--social-bi-control-height);
margin-bottom: 4px;
padding: 0 10px;
border: 1px solid #d8e5f0;
border-radius: 8px;
background: #ffffff;
color: #172033;
outline: none;
}
.social-bi-dropdown-menu--search input:focus {
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
}
.social-bi-option {
display: flex;
min-height: 34px;
align-items: center;
gap: 4px;
padding: 0 8px 0 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #34445a;
font: inherit;
font-size: 13px;
text-align: left;
transition:
background-color 160ms cubic-bezier(0.2, 0, 0, 1),
color 160ms cubic-bezier(0.2, 0, 0, 1);
}
.social-bi-option:hover,
.social-bi-option.is-selected {
background: #eef7ff;
color: #126fb2;
}
.social-bi-option .MuiCheckbox-root {
padding: 5px;
}
.social-bi-cascade-menu {
align-content: start;
}
.social-bi-cascade-group {
display: grid;
gap: 2px;
}
.social-bi-cascade-children {
display: grid;
gap: 2px;
padding-left: 22px;
}
.social-bi-dropdown-empty {
padding: 10px 8px;
color: #718198;
font-size: 13px;
}
.social-bi-query-button.MuiButton-root {
height: var(--social-bi-control-height);
min-height: var(--social-bi-control-height);
min-width: 84px;
border-radius: 6px;
background: #1688d9;
font-weight: 760;
}
.social-bi-query-button.MuiButton-root:hover {
background: #126fb2;
}
.social-bi-load-error {
align-self: center;
color: #c2410c;
font-size: 13px;
font-weight: 700;
}
.social-bi-summary-section {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.social-bi-summary-card {
display: grid;
min-height: 82px;
align-content: space-between;
gap: 6px;
padding: 12px;
border: 1px solid #e4ebf3;
border-radius: 6px;
background: #ffffff;
}
.social-bi-summary-card > span {
color: #718198;
font-size: 12px;
font-weight: 680;
}
.social-bi-summary-card strong {
color: #172033;
font-size: 22px;
font-weight: 800;
line-height: 1.1;
}
.social-bi-delta {
width: max-content;
padding: 3px 6px;
border-radius: 5px;
font-size: 11px;
font-weight: 760;
}
.social-bi-delta.is-up {
background: #ecfdf3;
color: #15803d;
}
.social-bi-delta.is-down {
background: #fff1f2;
color: #be123c;
}
.social-bi-table-section {
min-width: 0;
border: 1px solid #e4ebf3;
border-radius: 6px;
background: #ffffff;
}
.social-bi-table-head {
display: flex;
height: 42px;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 0 12px;
border-bottom: 1px solid #e4ebf3;
}
.social-bi-table-head span {
color: #718198;
font-size: 12px;
font-weight: 680;
}
.social-bi-table-scroll {
overflow-x: auto;
}
.social-bi-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
.social-bi-table th,
.social-bi-table td {
height: 34px;
padding: 0 8px;
border-bottom: 1px solid #edf2f7;
color: #34445a;
font-size: 12px;
text-align: right;
white-space: nowrap;
}
.social-bi-table th {
background: #f8fbfe;
color: #5f6f86;
font-size: 12px;
font-weight: 760;
}
.social-bi-table th.is-left,
.social-bi-table td.is-left {
text-align: left;
}
.social-bi-table th button {
display: inline-flex;
max-width: 100%;
align-items: center;
justify-content: flex-end;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
}
.social-bi-table th.is-left button {
justify-content: flex-start;
}
.social-bi-table th button:hover,
.social-bi-table th button.is-active {
color: #126fb2;
}
.social-bi-sort-arrow {
color: #8c9aab;
font-size: 11px;
}
.social-bi-table tr.is-total td {
position: sticky;
top: 0;
z-index: 1;
background: #f0f7ff;
color: #172033;
font-weight: 760;
}
.social-bi-pair-cell {
display: inline-grid;
min-width: 86px;
gap: 2px;
justify-items: end;
line-height: 1.15;
}
.social-bi-pair-cell strong {
color: #172033;
font-size: 12px;
font-weight: 760;
}
.social-bi-pair-cell small {
color: #718198;
font-size: 11px;
font-weight: 680;
}
.social-bi-progress-cell {
display: grid;
min-width: 150px;
gap: 4px;
}
.social-bi-progress-copy {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.social-bi-progress-copy strong {
color: #172033;
font-size: 12px;
font-weight: 760;
}
.social-bi-progress-copy span {
color: #126fb2;
font-size: 12px;
font-weight: 760;
}
.social-bi-progress-track {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: #e8eef5;
}
.social-bi-progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #1688d9, #1fbf75);
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.social-bi-goal-empty {
color: #8c9aab;
font-weight: 680;
}
.social-bi-empty-row td {
height: 64px;
color: #718198;
text-align: center;
}
.social-bi-skeleton {
position: relative;
display: block;
overflow: hidden;
border-radius: 6px;
background: #edf2f7;
}
.social-bi-skeleton::after {
position: absolute;
inset: 0;
animation: social-bi-shimmer 1.15s infinite;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75), transparent);
content: "";
transform: translateX(-100%);
}
.social-bi-skeleton--title {
width: 54%;
height: 14px;
}
.social-bi-skeleton--value {
width: 74%;
height: 28px;
}
.social-bi-skeleton--line {
width: 42%;
height: 20px;
}
.social-bi-skeleton--cell {
width: 100%;
height: 14px;
}
@keyframes social-bi-shimmer {
to {
transform: translateX(100%);
}
}
@media (max-width: 1360px) {
.social-bi-summary-section {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.social-bi-shell {
min-width: 0;
grid-template-columns: 64px minmax(0, 1fr);
}
.social-bi-filter-section {
align-items: stretch;
flex-direction: column;
}
.social-bi-field,
.social-bi-date-field,
.social-bi-dropdown--wide {
flex: 1 1 220px;
}
.social-bi-query-button.MuiButton-root {
width: 100%;
}
.social-bi-summary-section {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.social-bi-shell *,
.social-bi-shell *::before,
.social-bi-shell *::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
}
}
.social-bi-target-button.MuiButton-root {
height: var(--social-bi-control-height);
min-height: var(--social-bi-control-height);
border-radius: 6px;
border-color: #1688d9;
color: #1688d9;
font-weight: 720;
}
.social-bi-scope-hint {
margin: 0;
padding: 8px 14px;
border: 1px solid #cfe6f8;
border-radius: 8px;
background: #eef7fe;
color: #0f5d96;
font-size: 13px;
font-weight: 640;
}
.social-bi-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 24px;
background: rgba(7, 22, 38, 0.45);
}
.social-bi-dialog {
display: flex;
width: min(860px, 100%);
max-height: min(80vh, 720px);
flex-direction: column;
overflow: hidden;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(9, 30, 51, 0.28);
}
.social-bi-dialog-header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 22px 12px;
border-bottom: 1px solid #e4ecf5;
}
.social-bi-dialog-header strong {
font-size: 16px;
color: #14263c;
}
.social-bi-dialog-header span {
color: #5b7089;
font-size: 13px;
}
.social-bi-dialog-body {
flex: 1;
overflow: auto;
padding: 8px 22px;
}
.social-bi-dialog-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.social-bi-dialog-table th,
.social-bi-dialog-table td {
padding: 9px 10px;
border-bottom: 1px solid #eef3f9;
text-align: left;
white-space: nowrap;
}
.social-bi-dialog-table th {
position: sticky;
top: 0;
background: #f7fafd;
color: #47607c;
font-weight: 720;
}
.social-bi-dialog-table input {
width: 120px;
height: 32px;
padding: 0 10px;
border: 1px solid #cddaea;
border-radius: 6px;
font-size: 13px;
}
.social-bi-dialog-table input:focus {
outline: none;
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.14);
}
.social-bi-dialog-empty {
margin: 18px 4px;
color: #5b7089;
font-size: 13px;
}
.social-bi-dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 22px 16px;
border-top: 1px solid #e4ecf5;
}

View File

@ -0,0 +1,601 @@
/* 社交 BI v2 设计基座 finance v2 同一设计语言深色海军侧栏 + 浅色内容区 + 蓝青渐变强调
各视图自己的样式放在 databi/src/social/views/*.css只允许使用这里定义的 token。 */
.sbi-shell {
--sbi-sidebar-bg: #101b2d;
--sbi-sidebar-hover: rgba(120, 165, 255, 0.12);
--sbi-accent: #2557d6;
--sbi-accent-2: #0e7490;
--sbi-accent-grad: linear-gradient(135deg, #2557d6, #0e7490);
--sbi-bg: #f6f8fb;
--sbi-card: #ffffff;
--sbi-border: #e3eaf3;
--sbi-text: #14263c;
--sbi-text-2: #5b7089;
--sbi-text-3: #8aa0b8;
--sbi-up: #0f9d58;
--sbi-down: #d93025;
--sbi-warn-bg: #fdf3e7;
--sbi-warn-text: #9a5b13;
--sbi-radius: 12px;
--sbi-shadow: 0 1px 2px rgba(16, 34, 56, 0.06), 0 4px 16px rgba(16, 34, 56, 0.05);
display: grid;
grid-template-columns: 216px minmax(0, 1fr);
min-height: 100vh;
min-width: 1080px;
background: var(--sbi-bg);
color: var(--sbi-text);
font-size: 14px;
}
/* ---------- 侧栏 ---------- */
.sbi-sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
flex-direction: column;
gap: 20px;
padding: 18px 12px;
background: var(--sbi-sidebar-bg);
color: #dbe7f7;
}
.sbi-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 2px 8px;
}
.sbi-brand-mark {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
background: var(--sbi-accent-grad);
color: #ffffff;
font-size: 13px;
font-weight: 800;
}
.sbi-brand-copy {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.sbi-brand-copy strong {
font-size: 15px;
color: #ffffff;
}
.sbi-brand-copy small {
color: #7e8db0;
font-size: 11px;
letter-spacing: 0.04em;
}
.sbi-nav {
display: grid;
gap: 4px;
}
.sbi-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: #a9bcd8;
font-size: 13.5px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.sbi-nav-item:hover {
background: var(--sbi-sidebar-hover);
color: #e9f2ff;
}
.sbi-nav-item.is-active {
background: var(--sbi-accent-grad);
color: #ffffff;
}
.sbi-scope-badge {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
padding: 10px 12px;
border: 1px solid rgba(126, 141, 176, 0.35);
border-radius: 9px;
color: #a9bcd8;
font-size: 12px;
}
.sbi-scope-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #38bdf8;
}
/* ---------- 主区 / 顶栏 ---------- */
.sbi-main {
display: flex;
min-width: 0;
flex-direction: column;
}
.sbi-topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 22px;
border-bottom: 1px solid var(--sbi-border);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
}
.sbi-topbar-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.sbi-date-presets {
display: flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid var(--sbi-border);
border-radius: 9px;
background: #f1f5fa;
}
.sbi-date-presets > button {
padding: 6px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--sbi-text-2);
font-size: 13px;
font-weight: 640;
cursor: pointer;
}
.sbi-date-presets > button.is-active {
background: #ffffff;
color: var(--sbi-accent);
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
}
.sbi-custom-range {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
color: var(--sbi-text-3);
}
.sbi-custom-range input {
height: 30px;
padding: 0 8px;
border: 1px solid var(--sbi-border);
border-radius: 7px;
background: #ffffff;
color: var(--sbi-text);
font-size: 12.5px;
}
.sbi-granularity {
display: flex;
gap: 4px;
padding: 3px;
border: 1px solid var(--sbi-border);
border-radius: 9px;
background: #f1f5fa;
}
.sbi-granularity > button {
padding: 6px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--sbi-text-2);
font-size: 13px;
font-weight: 640;
cursor: pointer;
}
.sbi-granularity > button.is-active {
background: #ffffff;
color: var(--sbi-accent);
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
}
.sbi-refresh {
display: grid;
width: 34px;
height: 34px;
place-items: center;
margin-left: auto;
border: 1px solid var(--sbi-border);
border-radius: 9px;
background: #ffffff;
color: var(--sbi-text-2);
cursor: pointer;
}
.sbi-refresh:hover {
color: var(--sbi-accent);
border-color: var(--sbi-accent);
}
.sbi-app-chips {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sbi-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border: 1px solid var(--sbi-border);
border-radius: 999px;
background: #ffffff;
color: var(--sbi-text-2);
font-size: 13px;
font-weight: 640;
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.sbi-chip:hover {
border-color: var(--sbi-accent);
}
.sbi-chip.is-active {
border-color: var(--sbi-accent);
color: var(--sbi-accent);
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
}
.sbi-chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.sbi-region-select {
position: relative;
}
.sbi-region-trigger {
height: 32px;
padding: 0 14px;
border: 1px solid var(--sbi-border);
border-radius: 999px;
background: #ffffff;
color: var(--sbi-text-2);
font-size: 13px;
font-weight: 640;
cursor: pointer;
}
.sbi-region-trigger:hover {
border-color: var(--sbi-accent);
}
.sbi-region-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 40;
display: grid;
gap: 2px;
min-width: 260px;
max-height: 360px;
overflow: auto;
padding: 8px;
border: 1px solid var(--sbi-border);
border-radius: 10px;
background: #ffffff;
box-shadow: var(--sbi-shadow);
}
.sbi-region-group {
display: grid;
gap: 2px;
padding-top: 4px;
border-top: 1px dashed var(--sbi-border);
}
.sbi-region-option {
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--sbi-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.sbi-region-option:hover {
background: #f1f5fa;
}
.sbi-region-option.is-group {
color: var(--sbi-text-2);
font-weight: 700;
}
.sbi-region-option.is-active {
background: rgba(37, 87, 214, 0.08);
color: var(--sbi-accent);
font-weight: 700;
}
.sbi-region-empty {
margin: 6px;
color: var(--sbi-text-3);
font-size: 12.5px;
}
.sbi-range-label {
color: var(--sbi-text-3);
font-size: 12.5px;
}
.sbi-loading-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--sbi-accent);
animation: sbi-pulse 900ms ease-in-out infinite;
}
@keyframes sbi-pulse {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
.sbi-error-banner {
display: flex;
flex-direction: column;
gap: 2px;
margin: 12px 22px 0;
padding: 10px 14px;
border: 1px solid #f3ddc0;
border-radius: 10px;
background: var(--sbi-warn-bg);
color: var(--sbi-warn-text);
font-size: 12.5px;
}
.sbi-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px 22px 40px;
}
/* ---------- 视图共用原语(卡片/表格/骨架/空态/徽标) ---------- */
.sbi-card {
border: 1px solid var(--sbi-border);
border-radius: var(--sbi-radius);
background: var(--sbi-card);
box-shadow: var(--sbi-shadow);
}
.sbi-card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px 0;
}
.sbi-card-header strong {
font-size: 14.5px;
}
.sbi-card-header small {
color: var(--sbi-text-3);
font-size: 12px;
}
.sbi-card-toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.sbi-seg {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--sbi-border);
border-radius: 8px;
background: #f1f5fa;
}
.sbi-seg > button {
padding: 4px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--sbi-text-2);
font-size: 12px;
font-weight: 640;
cursor: pointer;
}
.sbi-seg > button.is-active {
background: #ffffff;
color: var(--sbi-accent);
box-shadow: 0 1px 2px rgba(16, 34, 56, 0.12);
}
.sbi-delta {
font-size: 12px;
font-weight: 700;
}
.sbi-delta.is-up {
color: var(--sbi-up);
}
.sbi-delta.is-down {
color: var(--sbi-down);
}
.sbi-delta.is-flat {
color: var(--sbi-text-3);
}
.sbi-table-scroll {
overflow: auto;
}
.sbi-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
}
.sbi-table th {
position: sticky;
top: 0;
z-index: 2;
padding: 10px 12px;
border-bottom: 1px solid var(--sbi-border);
background: #f7fafd;
color: var(--sbi-text-2);
font-weight: 700;
text-align: right;
white-space: nowrap;
}
.sbi-table th.is-left,
.sbi-table td.is-left {
text-align: left;
}
.sbi-table td {
padding: 9px 12px;
border-bottom: 1px solid #eef3f9;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.sbi-table tbody tr:hover td {
background: #f4f8fd;
}
.sbi-table tr.is-total td {
background: #f0f5fc;
font-weight: 720;
}
.sbi-empty {
display: grid;
gap: 6px;
place-items: center;
padding: 46px 20px;
color: var(--sbi-text-3);
font-size: 13px;
text-align: center;
}
.sbi-empty strong {
color: var(--sbi-text-2);
font-size: 14px;
}
.sbi-skeleton {
display: inline-block;
border-radius: 6px;
background: linear-gradient(90deg, #edf2f8 25%, #f7fafd 50%, #edf2f8 75%);
background-size: 200% 100%;
animation: sbi-shimmer 1.1s linear infinite;
}
@keyframes sbi-shimmer {
to {
background-position: -200% 0;
}
}
.sbi-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: rgba(37, 87, 214, 0.08);
color: var(--sbi-accent);
font-size: 11.5px;
font-weight: 700;
}
.sbi-badge.is-warn {
background: var(--sbi-warn-bg);
color: var(--sbi-warn-text);
}
.sbi-metric-hint {
cursor: help;
border-bottom: 1px dotted var(--sbi-text-3);
}
@media (max-width: 1280px) {
.sbi-shell {
grid-template-columns: 68px minmax(0, 1fr);
}
.sbi-brand-copy,
.sbi-nav-item span,
.sbi-scope-badge {
display: none;
}
.sbi-nav-item {
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.sbi-shell *,
.sbi-shell *::before,
.sbi-shell *::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
}
}

View File

@ -98,9 +98,10 @@ export async function listFinanceRechargeDetails(query = {}, apps = []) {
}
const mergedPageSize = page * pageSize;
const pages = await Promise.all(
const pageResults = await Promise.allSettled(
appCodes.map((appCode) => listRechargeBillsForApp(appCode, { ...cleanQuery, page: 1, page_size: mergedPageSize }))
);
const pages = pageResults.filter((result) => result.status === "fulfilled").map((result) => result.value);
const items = pages
.flatMap((data) => data.items || [])
.sort((left, right) => (right.createdAtMs || 0) - (left.createdAtMs || 0) || String(right.transactionId).localeCompare(String(left.transactionId)));
@ -149,7 +150,11 @@ export async function fetchFinanceRechargeSummaries(query = {}, apps = []) {
if (!appList.length && selectedAppCode) {
appList.push({ appCode: selectedAppCode, appName: selectedAppCode });
}
const summaries = await Promise.all(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery)));
// 单个 App 查询失败(如后端灰度中)按空汇总降级,不拖垮其余 App 的数据。
const results = await Promise.allSettled(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery)));
const summaries = results.map((result) =>
result.status === "fulfilled" ? result.value : normalizeRechargeSummary({})
);
const perApp = appList.map((app, index) => ({ ...app, summary: summaries[index] }));
return { merged: mergeRechargeSummaries(summaries), perApp };
}
@ -164,8 +169,10 @@ export async function fetchFinanceRechargeOverview(query = {}, apps = []) {
if (!appCodes.length) {
return normalizeRechargeOverview({});
}
const overviews = await Promise.all(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery)));
return mergeRechargeOverviews(overviews);
const results = await Promise.allSettled(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery)));
return mergeRechargeOverviews(
results.filter((result) => result.status === "fulfilled").map((result) => result.value)
);
}
export async function exportFinanceRechargeBills(appCode, query = {}) {

View File

@ -202,8 +202,10 @@ export function FinanceOverview({
);
}
// WithdrawalKpi USDT + USDT
// /
// WithdrawalKpi
// USDT
// USDTlikei
// /
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
const data = withdrawal || {
approvedCount: 0,
@ -212,35 +214,47 @@ function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
transferCount: 0,
transferUsdMinor: 0,
};
const metaParts = [];
if (totalUsd > 0) {
const share = (data.totalUsdMinor / totalUsd) * 100;
metaParts.push(`占充值 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
}
metaParts.push(`${formatAmount(data.transferCount + data.approvedCount)}`);
return (
<button
className="finance-kpi finance-kpi--withdrawal"
title="点击进入用户提现申请"
type="button"
onClick={onOpenWithdrawals}
>
<span className="finance-kpi__label">用户提现</span>
<strong className="finance-kpi__value">
{loading
? "…"
: `USDT ${(data.totalUsdMinor / 100).toLocaleString("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`}
</strong>
<small className="finance-kpi__meta">{loading ? "统计中" : metaParts.join(" · ")}</small>
<small className="finance-kpi__meta">
{loading
? ""
: `转币商 ${formatUsdMinor(data.transferUsdMinor, "USDT")} · 提现审批 ${formatUsdMinor(data.approvedUsdMinor, "USDT")}`}
</small>
</button>
<>
<button
className="finance-kpi finance-kpi--withdrawal"
title="点击进入用户提现申请"
type="button"
onClick={onOpenWithdrawals}
>
<span className="finance-kpi__label">用户提现</span>
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.approvedUsdMinor)}</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : outflowMeta(data.approvedUsdMinor, data.approvedCount, totalUsd)}
</small>
<small className="finance-kpi__meta">提现申请审核通过</small>
</button>
<div className="finance-kpi finance-kpi--salary">
<span className="finance-kpi__label">工资兑换</span>
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.transferUsdMinor)}</strong>
<small className="finance-kpi__meta">
{loading ? "统计中" : outflowMeta(data.transferUsdMinor, data.transferCount, totalUsd)}
</small>
<small className="finance-kpi__meta">用户工资转币商</small>
</div>
</>
);
}
function usdtText(usdMinor) {
return `USDT ${(usdMinor / 100).toLocaleString("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`;
}
function outflowMeta(usdMinor, count, totalUsd) {
const parts = [];
if (totalUsd > 0) {
const share = (usdMinor / totalUsd) * 100;
parts.push(`占充值 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
}
parts.push(`${formatAmount(count)}`);
return parts.join(" · ");
}
function kpiMeta(bucket, key, totalUsd, prevSummary) {
const parts = [`${formatAmount(bucket.billCount)}`];
if (key !== "total" && totalUsd > 0) {

View File

@ -259,6 +259,11 @@ a {
border-left-color: #be185d;
}
.finance-kpi--salary {
border-left-color: #7c3aed;
cursor: default;
}
.finance-kpi--active {
border-color: var(--primary, #4285f4);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary, #4285f4) 24%, transparent);