新数据指标
This commit is contained in:
parent
6ed13c690c
commit
cb4dcd87cf
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
@ -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" };
|
||||
}
|
||||
312
databi/src/social/SocialBiApp.jsx
Normal file
312
databi/src/social/SocialBiApp.jsx
Normal 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
121
databi/src/social/format.js
Normal 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);
|
||||
}
|
||||
267
databi/src/social/metrics.js
Normal file
267
databi/src/social/metrics.js
Normal 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
206
databi/src/social/state.js
Normal 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;
|
||||
}
|
||||
204
databi/src/social/useSocialBiData.js
Normal file
204
databi/src/social/useSocialBiData.js
Normal 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/appDaily(App 粒度)、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;
|
||||
}
|
||||
318
databi/src/social/views/DataTableView.jsx
Normal file
318
databi/src/social/views/DataTableView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
databi/src/social/views/OverviewView.jsx
Normal file
489
databi/src/social/views/OverviewView.jsx
Normal file
@ -0,0 +1,489 @@
|
||||
// OverviewView:经营概览首屏 —— Hero KPI、充值趋势、渠道构成、App 对比、区域 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} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
||||
});
|
||||
const header = list[0]?.axisValueLabel || list[0]?.name || "";
|
||||
if (list.length > 1) {
|
||||
lines.push(`合计 <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>
|
||||
);
|
||||
}
|
||||
465
databi/src/social/views/RegionsView.jsx
Normal file
465
databi/src/social/views/RegionsView.jsx
Normal 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_config,hyapp 在区域管理)</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
400
databi/src/social/views/RetentionView.jsx
Normal file
400
databi/src/social/views/RetentionView.jsx
Normal file
@ -0,0 +1,400 @@
|
||||
// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
|
||||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 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>
|
||||
);
|
||||
}
|
||||
601
databi/src/social/views/TeamKpiView.jsx
Normal file
601
databi/src/social/views/TeamKpiView.jsx
Normal file
@ -0,0 +1,601 @@
|
||||
// TeamKpiView:人员绩效视图。kpi_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 分;空串视为 0(清除目标),NaN/负数返回 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-targets(replaceSocialBiKpiTargets),月/日目标填 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>
|
||||
);
|
||||
}
|
||||
100
databi/src/social/views/data-table-view.css
Normal file
100
databi/src/social/views/data-table-view.css
Normal 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;
|
||||
}
|
||||
195
databi/src/social/views/overview-view.css
Normal file
195
databi/src/social/views/overview-view.css
Normal 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));
|
||||
}
|
||||
}
|
||||
222
databi/src/social/views/regions-view.css
Normal file
222
databi/src/social/views/regions-view.css
Normal 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);
|
||||
}
|
||||
102
databi/src/social/views/retention-view.css
Normal file
102
databi/src/social/views/retention-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
371
databi/src/social/views/team-kpi-view.css
Normal file
371
databi/src/social/views/team-kpi-view.css
Normal file
@ -0,0 +1,371 @@
|
||||
/* 人员绩效视图(TeamKpiView)专属样式:仅消费 social-v2.css 的 token,类名前缀 sbi-kpi-。
|
||||
金银铜名次徽标与图表网格色 #eef3f9 为本视图的功能色,其余颜色一律走 token。 */
|
||||
|
||||
.sbi-kpi-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Hero:1 大卡(环形进度)+ 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);
|
||||
}
|
||||
@ -6,5 +6,4 @@
|
||||
@import "./charts.css";
|
||||
@import "./tables.css";
|
||||
@import "./report.css";
|
||||
@import "./social-bi.css";
|
||||
@import "./responsive.css";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
601
databi/src/styles/social-v2.css
Normal file
601
databi/src/styles/social-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 = {}) {
|
||||
|
||||
@ -202,8 +202,10 @@ export function FinanceOverview({
|
||||
);
|
||||
}
|
||||
|
||||
// WithdrawalKpi 是“用户提现”卡:工资转币商的 USDT + 审核通过的提现申请 USDT;点击进入提现审核页。
|
||||
// 提现不是充值渠道,不参与来源筛选联动,占比按“提现 / 充值总和”表达资金回流强度。
|
||||
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
||||
// 「用户提现」只统计提现申请审核通过的 USDT(点击进入提现审核页);
|
||||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
||||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
||||
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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user