diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 2f8901c..d5487e3 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -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 ; + return ; } return ; } diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index d0dbf34..1ab4409 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -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() { diff --git a/databi/src/charts/EChart.jsx b/databi/src/charts/EChart.jsx index 0f30217..072f5eb 100644 --- a/databi/src/charts/EChart.jsx +++ b/databi/src/charts/EChart.jsx @@ -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 }) { diff --git a/databi/src/components/SocialBiDashboard.jsx b/databi/src/components/SocialBiDashboard.jsx deleted file mode 100644 index ac7ec85..0000000 --- a/databi/src/components/SocialBiDashboard.jsx +++ /dev/null @@ -1,1575 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined"; -import AppsOutlined from "@mui/icons-material/AppsOutlined"; -import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; -import PublicOutlined from "@mui/icons-material/PublicOutlined"; -import QueryStatsOutlined from "@mui/icons-material/QueryStatsOutlined"; -import RepeatOutlined from "@mui/icons-material/RepeatOutlined"; -import Button from "@mui/material/Button"; -import Checkbox from "@mui/material/Checkbox"; -import { fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, replaceSocialBiKpiTargets } from "../api.js"; -import { - ALL_VALUE, - socialBiAppOptions, - socialBiFilterDefinitions, - socialBiOperatorOptions, - socialBiSections -} from "../data/socialBiDashboard.js"; -import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs, thisMonthRange, todayRange } from "../utils/time.js"; - -const collator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" }); -const SOCIAL_BI_STAT_TZ = DEFAULT_TIME_ZONE; -const dateRangeOptions = [ - { label: "昨日", value: "yesterday" }, - { label: "近 7 日", value: "last7" }, - { label: "近 30 日", value: "last30" }, - { label: "本月", value: "month" } -]; - -const sectionIcons = { - appData: AppsOutlined, - department: ApartmentOutlined, - personnel: GroupsOutlined, - regional: PublicOutlined, - retention: RepeatOutlined -}; - -export function SocialBiDashboard() { - const [activeSectionKey, setActiveSectionKey] = useState(socialBiSections[0].key); - const [filters, setFilters] = useState(() => createInitialFilters()); - const [master, setMaster] = useState(null); - const [loadingToken, setLoadingToken] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [loadError, setLoadError] = useState(""); - const [realRows, setRealRows] = useState(() => createEmptyRealRows()); - const [kpiData, setKpiData] = useState(null); - const [isTargetDialogOpen, setIsTargetDialogOpen] = useState(false); - const [openFilterKey, setOpenFilterKey] = useState(null); - const [sortBySection, setSortBySection] = useState(() => createInitialSortState()); - const filterSectionRef = useRef(null); - const filterOptions = useMemo(() => filterOptionsFromMaster(master), [master]); - const filterDefinitions = useMemo(() => createRuntimeFilterDefinitions(filterOptions), [filterOptions]); - const runtimeSections = useMemo(() => applyRealRowsToSections(realRows), [realRows]); - const activeSection = useMemo( - () => runtimeSections.find((section) => section.key === activeSectionKey) || runtimeSections[0], - [activeSectionKey, runtimeSections] - ); - const filteredRows = useMemo(() => applySectionFilters(activeSection, filters, filterDefinitions), [activeSection, filterDefinitions, filters]); - const sortState = sortBySection[activeSection.key] || defaultSortState(activeSection); - const sortedRows = useMemo(() => sortRows(filteredRows, activeSection.columns, sortState), [activeSection.columns, filteredRows, sortState]); - const totalRow = useMemo(() => createTotalRow(activeSection, filteredRows), [activeSection, filteredRows]); - const summaryCards = useMemo(() => createSummaryCards(activeSection, filteredRows, totalRow), [activeSection, filteredRows, totalRow]); - const canManageKpi = Boolean(master?.permissions?.kpi_manage); - const canViewAllKpi = Boolean(master?.permissions?.kpi_view_all); - - useEffect(() => { - let ignore = false; - const range = dateRangeToCalendarRange(filters.dateRange); - const appCodes = selectedSocialBiAppCodes(filters.apps, filterOptions.apps); - const startMs = rangeStartMs(range, SOCIAL_BI_STAT_TZ); - const endMs = rangeEndMs(range, SOCIAL_BI_STAT_TZ); - setIsLoading(true); - setLoadError(""); - Promise.allSettled([ - fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_STAT_TZ }), - fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_STAT_TZ }) - ]) - .then(([overviewResult, kpiResult]) => { - if (ignore) { - return; - } - const overview = overviewResult.status === "fulfilled" ? overviewResult.value : null; - const kpi = kpiResult.status === "fulfilled" ? kpiResult.value : null; - if (!overview && !kpi) { - setRealRows(createEmptyRealRows()); - setKpiData(null); - setLoadError(overviewResult.reason?.message || "数据加载失败"); - return; - } - setKpiData(kpi); - setRealRows(buildSocialBiRows({ kpi, overview, range })); - setLoadError(collectSocialBiErrors(overview, kpi, overviewResult, kpiResult)); - }) - .finally(() => { - if (!ignore) { - setIsLoading(false); - } - }); - return () => { - ignore = true; - }; - }, [filterOptions.apps, filters.apps, filters.dateRange, loadingToken]); - - useEffect(() => { - setOpenFilterKey(null); - }, [activeSectionKey]); - - useEffect(() => { - const handlePointerDown = (event) => { - if (!filterSectionRef.current?.contains(event.target)) { - setOpenFilterKey(null); - } - }; - - document.addEventListener("pointerdown", handlePointerDown); - return () => document.removeEventListener("pointerdown", handlePointerDown); - }, []); - - useEffect(() => { - let ignore = false; - fetchSocialBiMaster() - .then((data) => { - if (!ignore && data) { - setMaster(data); - } - }) - .catch(() => { - if (!ignore) { - setMaster(null); - } - }); - return () => { - ignore = true; - }; - }, []); - - const updateFilter = useCallback((key, value) => { - setFilters((current) => ({ ...current, [key]: value })); - }, []); - - const handleQuery = useCallback(() => { - setOpenFilterKey(null); - setLoadingToken((current) => current + 1); - }, []); - - const handleTargetsSaved = useCallback(() => { - setIsTargetDialogOpen(false); - setLoadingToken((current) => current + 1); - }, []); - - const toggleFilterPopup = useCallback((key) => { - setOpenFilterKey((current) => (current === key ? null : key)); - }, []); - - const handleSort = useCallback((column) => { - setSortBySection((current) => { - const previous = current[activeSection.key] || defaultSortState(activeSection); - const direction = previous.key === column.key && previous.direction === "desc" ? "asc" : "desc"; - return { - ...current, - [activeSection.key]: { direction, key: column.key } - }; - }); - }, [activeSection]); - - return ( -
- -
- setOpenFilterKey(null)} - onFilterChange={updateFilter} - onQuery={handleQuery} - onToggleDropdown={toggleFilterPopup} - openFilterKey={openFilterKey} - actions={ - activeSection.key === "personnel" && canManageKpi ? ( - - ) : null - } - /> - - {(activeSection.key === "personnel" || activeSection.key === "department") && master && !canViewAllKpi ? ( -

当前仅展示你负责的 App / 区域数据(我的 KPI)。

- ) : null} - - - - -
- {isTargetDialogOpen ? ( - setIsTargetDialogOpen(false)} - onSaved={handleTargetsSaved} - /> - ) : null} -
- ); -} - -function SocialBiSidebar({ activeKey, onSectionChange }) { - return ( - - ); -} - -function FilterSection({ - actions = null, - activeSection, - filterDefinitions, - filterRef, - filters, - isLoading, - loadError, - onCloseDropdowns, - onFilterChange, - onQuery, - onToggleDropdown, - openFilterKey -}) { - return ( -
-
- {activeSection.filters.map((filterKey) => { - const definition = filterDefinitions[filterKey]; - if (definition.type === "dateRange") { - return ( - { - onCloseDropdowns(); - onFilterChange(filterKey, value); - }} - value={filters[filterKey]} - /> - ); - } - if (definition.type === "searchMulti") { - return ( - onFilterChange(filterKey, value)} - onToggle={() => onToggleDropdown(filterKey)} - options={definition.options} - placeholder={definition.placeholder} - value={filters[filterKey]} - /> - ); - } - if (definition.type === "cascade") { - return ( - onFilterChange(filterKey, value)} - onToggle={() => onToggleDropdown(filterKey)} - options={definition.options} - value={filters[filterKey]} - /> - ); - } - return ( - onFilterChange(filterKey, value)} - onToggle={() => onToggleDropdown(filterKey)} - options={definition.options} - value={filters[filterKey]} - /> - ); - })} -
- - {actions} - {loadError ? {loadError} : null} -
- ); -} - -function DateRangeControl({ label, onChange, value }) { - const activeOption = dateRangeOptions.find((item) => item.value === value) || dateRangeOptions[0]; - return ( -
-
- {dateRangeOptions.map((option) => ( - - ))} -
-
- ); -} - -function MultiSelectControl({ isOpen, label, onChange, onToggle, options, value }) { - return ( -
- { - event.preventDefault(); - onToggle(); - }} - > - {label} - {selectedOptionsLabel(options, value)} - -
- {options.map((option) => ( - onChange(toggleFlatSelection(value, option.value, options))} - /> - ))} -
-
- ); -} - -function SearchableMultiSelectControl({ isOpen, label, onChange, onToggle, options, placeholder, value }) { - const [query, setQuery] = useState(""); - const filteredOptions = useMemo(() => { - const keyword = query.trim().toLowerCase(); - if (!keyword) { - return options; - } - return options.filter((option) => `${option.label} ${option.searchText || ""}`.toLowerCase().includes(keyword)); - }, [options, query]); - - return ( -
- { - event.preventDefault(); - onToggle(); - }} - > - {label} - {selectedOptionsLabel(options, value)} - -
- setQuery(event.target.value)} - placeholder={placeholder} - type="text" - value={query} - /> - {filteredOptions.map((option) => ( - onChange(toggleFlatSelection(value, option.value, options))} - /> - ))} - {!filteredOptions.length ? 当前无数据 : null} -
-
- ); -} - -function CascadeMultiSelectControl({ isOpen, label, onChange, onToggle, options, value }) { - return ( -
- { - event.preventDefault(); - onToggle(); - }} - > - {label} - {selectedCascadeLabel(options, value)} - -
- onChange([ALL_VALUE])} - /> - {options.map((group) => ( -
- onChange(toggleCascadeGroup(value, group))} - /> -
- {group.children.map((child) => ( - onChange(toggleCascadeChild(value, child.value))} - /> - ))} -
-
- ))} -
-
- ); -} - -function OptionToggle({ checked, label, onClick }) { - return ( - - ); -} - -function SummarySection({ cards, isLoading }) { - return ( -
- {cards.map((card) => ( -
- {isLoading ? ( - <> - - - - - ) : ( - <> - {card.label} - {formatTypedValue(card.value, card.type)} - - - )} -
- ))} -
- ); -} - -function WideDataTable({ columns, isLoading, onSort, rows, sortState, totalRow }) { - return ( -
-
- 首行汇总 Total -
-
- - - - {columns.map((column) => ( - - ))} - - - - {isLoading ? : null} - {!isLoading && rows.length ? ( - <> - - {rows.map((row) => ( - - ))} - - ) : null} - {!isLoading && !rows.length ? ( - - - - ) : null} - -
- -
当前无数据
-
-
- ); -} - -function SkeletonRows({ columns }) { - return Array.from({ length: 5 }, (_, rowIndex) => ( - - {columns.map((column) => ( - - - - ))} - - )); -} - -function DataRow({ columns, row, total = false }) { - return ( - - {columns.map((column) => ( - - {renderCell(row[column.key], column)} - - ))} - - ); -} - -function DeltaBadge({ value }) { - if (value === null || value === undefined || value === "" || !Number.isFinite(Number(value))) { - return null; - } - const isUp = Number(value) >= 0; - return ( - - 较昨日 {isUp ? "+" : ""} - {Number(value).toFixed(1)}% - - ); -} - -function ProgressCell({ value }) { - if (!value || value.noGoal || !value.target) { - return 无目标; - } - const rate = Math.max(0, Math.min(100, (Number(value.actual || 0) / Number(value.target || 1)) * 100)); - return ( -
-
- - {formatMoney(value.actual)} / {formatMoney(value.target)} - - {rate.toFixed(1)}% -
- -
- ); -} - -function renderCell(value, column) { - if (column.type === "progress") { - return ; - } - if (column.type === "ratio") { - if (!value) { - return "--"; - } - return `${formatNumber(value?.active || 0)} / ${formatNumber(value?.total || 0)}`; - } - if (column.type === "flowRate") { - return ; - } - if (column.type === "consumeOutput") { - const secondary = value?.delta === null || value?.delta === undefined ? "" : `差额 ${formatNumber(value.delta)}`; - return ; - } - if (column.type === "duration") { - return formatDuration(value); - } - return formatTypedValue(value, column.type); -} - -function MetricPairCell({ primary, secondary }) { - const hasPrimary = primary && primary !== "--"; - const hasSecondary = secondary && secondary !== "--"; - if (!hasPrimary && !hasSecondary) { - return "--"; - } - return ( - - {hasPrimary ? primary : "--"} - {hasSecondary ? {secondary} : null} - - ); -} - -function createEmptyRealRows() { - return { - appData: [], - department: [], - personnel: [], - regional: [], - retention: [] - }; -} - -function applyRealRowsToSections(realRows) { - return socialBiSections.map((section) => ({ - ...section, - rows: realRows[section.key] || [] - })); -} - -// buildSocialBiRows 把后端 databi 聚合结果映射成五个板块的表格行: -// App 数据/地区/留存来自 overview(服务端已按数据范围裁剪),人员绩效与部门看板来自 KPI 接口。 -function buildSocialBiRows({ kpi, overview, range }) { - const rows = createEmptyRealRows(); - (overview?.apps || []).forEach((app) => { - if (app.error) { - return; - } - rows.appData.push(...createAppDailyRows(app, range)); - const regionRows = createRegionRows(app, range); - rows.regional.push(...regionRows.map(createRegionalRow)); - rows.retention.push(...regionRows.map(createRetentionRow)); - }); - const kpiRows = createKpiRows(kpi, range); - rows.personnel = kpiRows; - rows.department = createDepartmentRows(kpiRows, range); - return rows; -} - -function collectSocialBiErrors(overview, kpi, overviewResult, kpiResult) { - const errors = []; - if (overviewResult?.status === "rejected") { - errors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`); - } - if (kpiResult?.status === "rejected") { - errors.push(`KPI 数据加载失败: ${kpiResult.reason?.message || "未知错误"}`); - } - (overview?.apps || []).forEach((app) => { - if (app.error) { - errors.push(`${app.app_name || app.app_code}: ${app.error}`); - } - }); - Object.entries(kpi?.app_errors || {}).forEach(([appCode, message]) => { - errors.push(`${appCode}: ${message}`); - }); - return errors.join(";"); -} - -function createAppDailyRows(app, range) { - const appName = app.app_name || appCodeLabel(app.app_code); - const series = Array.isArray(app.daily_series) ? app.daily_series : []; - // 单日区间直接用整段合计(保留留存等区间口径字段),多日区间展开成 天×App 行,与截图口径一致。 - if (series.length > 1) { - return series.map((dayRow) => ({ - appCode: app.app_code, - appName, - date: dayRow.stat_day || dayRow.label || rangeLabel(range), - id: `app-${app.app_code}-${dayRow.stat_day || dayRow.label}`, - ...createSocialMetricFields(dayRow) - })); - } - return [ - { - appCode: app.app_code, - appName, - date: rangeLabel(range), - id: `app-${app.app_code}`, - ...createSocialMetricFields(app.total || {}) - } - ]; -} - -function createRegionRows(app, range) { - const appName = app.app_name || appCodeLabel(app.app_code); - const daily = Array.isArray(app.daily_region_breakdown) ? app.daily_region_breakdown : []; - const totals = Array.isArray(app.region_breakdown) ? app.region_breakdown : []; - const useDaily = daily.length > 0 && new Set(daily.map((row) => row.stat_day || row.label)).size > 1; - const source = useDaily ? daily : totals; - return source.map((row, index) => { - const regionId = Number(row.region_id ?? 0); - const date = useDaily ? row.stat_day || row.label || rangeLabel(range) : rangeLabel(range); - return { - appCode: app.app_code, - appName, - date, - id: `${app.app_code}-region-${regionId || index}-${date}`, - region: row.region_name || row.region_code || `区域 ${regionId}`, - regionId, - regionKey: `${app.app_code}:${regionId}`, - ...createSocialMetricFields(row) - }; - }); -} - -function createKpiRows(kpi, range) { - const days = rangeDayCount(range); - return (kpi?.items || []).map((item) => { - const rangeRecharge = moneyMajor(readOptionalNumber(item, "range_recharge_usd_minor")); - const monthRecharge = moneyMajor(readOptionalNumber(item, "month_recharge_usd_minor")); - const monthTarget = moneyMajor(Number(item.month_target_usd_minor || 0)) || 0; - const dailyTarget = moneyMajor(Number(item.daily_target_usd_minor || 0)) || 0; - const dayTarget = dailyTarget > 0 ? dailyTarget * days : monthTarget > 0 ? (monthTarget / 30) * days : 0; - return { - appCode: item.app_code, - appName: item.app_name || appCodeLabel(item.app_code), - dataError: item.data_error || "", - date: rangeLabel(range), - dayProgress: dayTarget > 0 ? { actual: rangeRecharge || 0, target: dayTarget } : { actual: rangeRecharge || 0, noGoal: true, target: 0 }, - id: `kpi-${item.operator_user_id}-${item.app_code}-${item.region_id}`, - monthKpiRate: item.month_attainment_rate === null || item.month_attainment_rate === undefined ? null : Number(item.month_attainment_rate) * 100, - monthProgress: monthTarget > 0 ? { actual: monthRecharge || 0, target: monthTarget } : { actual: monthRecharge || 0, noGoal: true, target: 0 }, - monthRecharge, - monthTarget: monthTarget > 0 ? monthTarget : null, - newUsers: readOptionalNumber(item, "range_new_users"), - operator: item.operator_name || item.operator_account || `#${item.operator_user_id}`, - operatorUserId: item.operator_user_id, - recharge: rangeRecharge, - region: item.region_name || "全部区域", - regionId: Number(item.region_id ?? 0), - regionKey: `${item.app_code}:${Number(item.region_id ?? 0)}`, - team: item.team || "", - teamId: item.team_id ?? null - }; - }); -} - -function createDepartmentRows(kpiRows, range) { - const groups = new Map(); - kpiRows.forEach((row) => { - const team = row.team || "未分组"; - const key = `${team}|${row.appCode}`; - const group = groups.get(key) || { - appCode: row.appCode, - appName: row.appName, - date: rangeLabel(range), - dayActual: 0, - dayTarget: 0, - department: team, - id: `dept-${key}`, - monthActual: 0, - monthRecharge: 0, - monthTarget: 0, - newUsers: null, - recharge: null, - regions: new Set(), - team - }; - group.recharge = sumOptionalNumbers(group.recharge, row.recharge); - group.newUsers = sumOptionalNumbers(group.newUsers, row.newUsers); - group.monthRecharge = sumOptionalNumbers(group.monthRecharge, row.monthRecharge) || 0; - group.monthTarget += row.monthTarget || 0; - group.dayActual += Number(row.dayProgress?.actual || 0); - group.dayTarget += row.dayProgress?.noGoal ? 0 : Number(row.dayProgress?.target || 0); - group.monthActual += Number(row.monthProgress?.actual || 0); - group.regions.add(row.region); - groups.set(key, group); - }); - return [...groups.values()].map((group) => { - const regionNames = [...group.regions]; - return { - appCode: group.appCode, - appName: group.appName, - date: group.date, - dayProgress: group.dayTarget > 0 ? { actual: group.dayActual, target: group.dayTarget } : { actual: group.dayActual, noGoal: true, target: 0 }, - department: group.department, - id: group.id, - monthKpiRate: group.monthTarget > 0 ? (group.monthActual / group.monthTarget) * 100 : null, - monthProgress: group.monthTarget > 0 ? { actual: group.monthActual, target: group.monthTarget } : { actual: group.monthActual, noGoal: true, target: 0 }, - monthRecharge: group.monthRecharge, - monthTarget: group.monthTarget > 0 ? group.monthTarget : null, - newUsers: group.newUsers, - recharge: group.recharge, - region: regionNames.length > 2 ? `${regionNames.slice(0, 2).join(" / ")} 等${regionNames.length}个` : regionNames.join(" / "), - team: group.team - }; - }); -} - -function rangeDayCount(range) { - const start = new Date(`${range.start}T00:00:00Z`).getTime(); - const end = new Date(`${range.end}T00:00:00Z`).getTime(); - if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) { - return 1; - } - return Math.round((end - start) / 86400000) + 1; -} - -function createSocialMetricFields(source) { - return { - arpu: moneyMajor(readOptionalNumber(source, "arpu_usd_minor", "arpuUsdMinor")), - arppu: moneyMajor(readOptionalNumber(source, "arppu_usd_minor", "arppuUsdMinor", "up_value_usd_minor", "upValueUsdMinor")), - coinSellerRecharge: moneyMajor(readOptionalNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor")), - coinSellerStockCoin: readOptionalNumber(source, "coin_seller_stock_coin", "coinSellerStockCoin"), - coinSellerTransferCoin: readOptionalNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin"), - coins: readOptionalNumber(source, "coin_total", "coinTotal"), - consumedCoin: readOptionalNumber(source, "consumed_coin", "consumedCoin"), - consumeOutputRatio: consumeOutputValue(source), - dau: readOptionalNumber(source, "active_users", "activeUsers"), - gameFlowProfit: metricFlowRateValue(source, ["game_turnover", "gameTurnover"], ["game_profit_rate", "gameProfitRate"], ["game_profit", "gameProfit"]), - giftCoinSpent: readOptionalNumber(source, "gift_coin_spent", "giftCoinSpent"), - googleRecharge: moneyMajor(readOptionalNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor")), - luckyGiftFlowProfit: metricFlowRateValue( - source, - ["lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover"], - ["lucky_gift_profit_rate", "luckyGiftProfitRate"], - ["lucky_gift_profit", "luckyGiftProfit"] - ), - manualGrantCoin: readOptionalNumber(source, "manual_grant_coin", "manualGrantCoin"), - avgMicOnlineMs: readOptionalNumber(source, "avg_mic_online_ms", "avgMicOnlineMs"), - micOnlineMs: readOptionalNumber(source, "mic_online_ms", "micOnlineMs"), - newUserRecharge: moneyMajor(readOptionalNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor")), - newUsers: readOptionalNumber(source, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers"), - outputCoin: readOptionalNumber(source, "output_coin", "outputCoin"), - paidConversionRate: percentageValue(readOptionalNumber(source, "paid_conversion_rate", "paidConversionRate", "payer_rate", "payerRate")), - paidUsers: readOptionalNumber(source, "paid_users", "paidUsers"), - platformGrantCoin: readOptionalNumber(source, "platform_grant_coin", "platformGrantCoin"), - recharge: moneyMajor(readOptionalNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor")), - rechargeConversionRate: percentageValue(readOptionalNumber(source, "recharge_conversion_rate", "rechargeConversionRate")), - rechargeUsers: readOptionalNumber(source, "recharge_users", "rechargeUsers"), - retentionD1: retentionPercent(source, "d1_retention_rate", "day1_rate"), - retentionD7: retentionPercent(source, "d7_retention_rate", "day7_rate"), - retentionD30: retentionPercent(source, "d30_retention_rate", "day30_rate"), - salary: moneyMajor(readOptionalNumber(source, "salary_usd_minor", "salaryUsdMinor")), - salaryTransferCoin: readOptionalNumber(source, "salary_transfer_coin", "salaryTransferCoin"), - superLuckyGiftFlowProfit: metricFlowRateValue( - source, - ["super_lucky_gift_turnover", "superLuckyGiftTurnover"], - ["super_lucky_gift_profit_rate", "superLuckyGiftProfitRate"], - ["super_lucky_gift_profit", "superLuckyGiftProfit"] - ), - thirdPartyRecharge: moneyMajor(readOptionalNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor")), - turnover: appTurnoverValue(source) - }; -} - -function createRegionalRow(row) { - return { ...row, id: `regional-${row.id}` }; -} - -function createRetentionRow(row) { - return { ...row, id: `retention-${row.id}` }; -} - -function selectedSocialBiAppCodes(selectedValues, appOptions) { - const options = appOptions?.length ? appOptions : socialBiAppOptions; - const selected = selectedValues?.length ? selectedValues : [ALL_VALUE]; - const values = selected.includes(ALL_VALUE) - ? options.map((option) => option.value) - : selected; - return [...new Set(values.filter((value) => value && value !== ALL_VALUE).map((value) => String(value).toLowerCase()))]; -} - -function dateRangeToCalendarRange(value) { - if (value === "last7") { - return lastDaysRangeValue(7); - } - if (value === "last30") { - return lastDaysRangeValue(30); - } - if (value === "month") { - return thisMonthRange(SOCIAL_BI_STAT_TZ); - } - const today = todayRange(SOCIAL_BI_STAT_TZ).start; - const yesterday = shiftDateValue(today, -1); - return { end: yesterday, endTime: "23:59:59", start: yesterday, startTime: "00:00:00" }; -} - -function lastDaysRangeValue(days) { - const today = todayRange(SOCIAL_BI_STAT_TZ).start; - return { - end: today, - endTime: "23:59:59", - start: shiftDateValue(today, 1 - Number(days || 1)), - startTime: "00:00:00" - }; -} - -function shiftDateValue(value, days) { - const [year, month, day] = String(value).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 rangeLabel(range) { - return range?.start === range?.end ? range.start : `${range.start} ~ ${range.end}`; -} - -function appCodeLabel(appCode) { - const normalized = String(appCode || "").toLowerCase(); - return socialBiAppOptions.find((option) => option.value === normalized)?.label || normalized || "-"; -} - -function retentionPercent(source, topLevelKey, retentionKey) { - const value = readOptionalNumber(source, topLevelKey, retentionKey) ?? readOptionalNumber(source?.retention, retentionKey, topLevelKey); - return value === null ? null : value * 100; -} - -function percentageValue(value) { - return value === null || value === undefined ? null : Number(value) * 100; -} - -function metricFlowRateValue(source, valueKeys, rateKeys, profitKeys) { - const value = readFirstOptionalNumber(source, valueKeys); - const profit = readFirstOptionalNumber(source, profitKeys); - const explicitRate = readFirstOptionalNumber(source, rateKeys); - const rate = explicitRate === null && value && profit !== null ? profit / value : explicitRate; - if (value === null && rate === null && profit === null) { - return null; - } - return { profit, rate: percentageValue(rate), value }; -} - -function consumeOutputValue(source) { - const consumed = readOptionalNumber(source, "consumed_coin", "consumedCoin"); - const output = readOptionalNumber(source, "output_coin", "outputCoin"); - const explicitRate = readOptionalNumber(source, "consume_output_ratio", "consumeOutputRatio"); - const rate = explicitRate === null && consumed && output !== null ? output / consumed : explicitRate; - const delta = readOptionalNumber(source, "consume_output_delta", "consumeOutputDelta"); - if (consumed === null && output === null && rate === null && delta === null) { - return null; - } - return { consumed, delta, output, rate: percentageValue(rate) }; -} - -function appTurnoverValue(source) { - return sumOptionalNumbers( - readOptionalNumber(source, "gift_coin_spent", "giftCoinSpent"), - readOptionalNumber(source, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover"), - readOptionalNumber(source, "super_lucky_gift_turnover", "superLuckyGiftTurnover"), - readOptionalNumber(source, "game_turnover", "gameTurnover") - ); -} - -function readFirstOptionalNumber(source, keys) { - return readOptionalNumber(source, ...(keys || [])); -} - -function readOptionalNumber(source, ...keys) { - for (const key of keys) { - if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") { - const value = Number(source[key]); - return Number.isFinite(value) ? value : null; - } - } - return null; -} - -function moneyMajor(value) { - return value === null || value === undefined ? null : value / 100; -} - -function sumOptionalNumbers(...values) { - const validValues = values.filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))); - if (!validValues.length) { - return null; - } - return validValues.reduce((sum, value) => sum + Number(value), 0); -} - -function pad2(value) { - return String(value).padStart(2, "0"); -} - -function createInitialFilters() { - return { - apps: [ALL_VALUE], - dateRange: "yesterday", - departments: [ALL_VALUE], - operators: [ALL_VALUE], - regions: [ALL_VALUE] - }; -} - -function createInitialSortState() { - return Object.fromEntries(socialBiSections.map((section) => [section.key, defaultSortState(section)])); -} - -function defaultSortState(section) { - const revenueColumn = section.columns.find((column) => column.key === "recharge"); - return { direction: "desc", key: revenueColumn?.key || section.columns[0].key }; -} - -// filterOptionsFromMaster 把 master 主数据(已按登录用户数据范围裁剪)转成筛选器选项: -// App 多选、按 App 分组的区域级联、带负责范围的运营人员、按 team 聚合的部门。 -function filterOptionsFromMaster(master) { - if (!master) { - return { - apps: socialBiAppOptions, - departments: [{ label: "全部部门", value: ALL_VALUE }], - operators: socialBiOperatorOptions, - regions: [] - }; - } - const apps = [ - { label: "全部 App", value: ALL_VALUE }, - ...(master.apps || []).map((app) => ({ label: app.app_name || app.app_code, value: app.app_code })) - ]; - const operators = [ - { label: "全部人员", scopes: [], searchText: "all 全部人员", value: ALL_VALUE }, - ...(master.operators || []).map((operator) => ({ - label: operator.name || operator.account || `#${operator.user_id}`, - scopes: operator.scopes || [], - searchText: [operator.name, operator.account, operator.team].filter(Boolean).join(" ").toLowerCase(), - value: String(operator.user_id) - })) - ]; - const teamNames = [...new Set((master.operators || []).map((operator) => operator.team).filter(Boolean))]; - const departments = [ - { label: "全部部门", value: ALL_VALUE }, - ...teamNames.map((name) => ({ label: name, value: name })), - ...((master.operators || []).some((operator) => !operator.team) ? [{ label: "未分组", value: "未分组" }] : []) - ]; - const regionsByApp = new Map(); - (master.regions || []).forEach((region) => { - const list = regionsByApp.get(region.app_code) || []; - list.push(region); - regionsByApp.set(region.app_code, list); - }); - const regions = (master.apps || []) - .map((app) => ({ - children: (regionsByApp.get(app.app_code) || []).map((region) => ({ - label: region.region_name || region.region_code || `区域 ${region.region_id}`, - value: `${app.app_code}:${region.region_id}` - })), - label: app.app_name || app.app_code, - value: `app:${app.app_code}` - })) - .filter((group) => group.children.length); - return { apps, departments, operators, regions }; -} - -function createRuntimeFilterDefinitions(filterOptions) { - return { - ...socialBiFilterDefinitions, - apps: { ...socialBiFilterDefinitions.apps, options: filterOptions.apps }, - departments: { ...socialBiFilterDefinitions.departments, options: filterOptions.departments }, - operators: { ...socialBiFilterDefinitions.operators, options: filterOptions.operators }, - regions: { ...socialBiFilterDefinitions.regions, options: filterOptions.regions } - }; -} - -function applySectionFilters(section, filters, filterDefinitions) { - const operatorScopes = selectedOperatorScopes(filters.operators, filterDefinitions.operators.options); - return section.rows.filter((row) => { - if (!matchesAppSelection(row.appCode, filters.apps)) { - return false; - } - if (section.filters.includes("departments") && !matchesDepartmentSelection(row, filters.departments)) { - return false; - } - if (section.filters.includes("operators") && !matchesOperatorSelection(row, filters.operators, operatorScopes)) { - return false; - } - if (section.filters.includes("regions") && !matchesRegionSelection(row, filters.regions)) { - return false; - } - return true; - }); -} - -function matchesAppSelection(appCode, values) { - if (!values?.length || values.includes(ALL_VALUE) || !appCode) { - return true; - } - return values.includes(String(appCode).toLowerCase()); -} - -function matchesDepartmentSelection(row, values) { - if (!values?.length || values.includes(ALL_VALUE)) { - return true; - } - return values.includes(row.team || row.department || "未分组"); -} - -function selectedOperatorScopes(values, options) { - if (!values?.length || values.includes(ALL_VALUE)) { - return null; - } - const scopes = []; - options.forEach((option) => { - if (values.includes(option.value)) { - scopes.push(...(option.scopes || [])); - } - }); - return scopes; -} - -// 人员绩效行按 operatorUserId 精确过滤;其它板块按所选人员的负责范围(App×区域)过滤行。 -function matchesOperatorSelection(row, values, operatorScopes) { - if (!values?.length || values.includes(ALL_VALUE)) { - return true; - } - if (row.operatorUserId !== undefined) { - return values.includes(String(row.operatorUserId)); - } - if (!operatorScopes) { - return true; - } - if (!operatorScopes.length) { - return false; - } - const appCode = String(row.appCode || "").toLowerCase(); - return operatorScopes.some((scope) => { - if (String(scope.app_code || "").toLowerCase() !== appCode) { - return false; - } - if (row.regionId === undefined || scope.region_id === 0) { - return true; - } - return Number(scope.region_id) === Number(row.regionId); - }); -} - -function matchesRegionSelection(row, values) { - if (!values?.length || values.includes(ALL_VALUE)) { - return true; - } - return values.includes(`app:${row.appCode}`) || values.includes(row.regionKey); -} - -function createSummaryCards(section, rows, totalRow) { - return section.cards.map((card) => { - // KPI 达成率是比值,用充值合计/目标合计的加权口径,而不是逐行平均。 - if (card.key === "monthKpiRate") { - const monthRecharge = sumOptionalNumbers(...rows.map((row) => row.monthRecharge)); - const monthTarget = sumOptionalNumbers(...rows.map((row) => row.monthTarget)); - return { ...card, delta: null, value: monthTarget ? ((monthRecharge || 0) / monthTarget) * 100 : null }; - } - return { - ...card, - delta: null, - value: totalRow[card.key] ?? aggregateLooseValue(rows, card) - }; - }); -} - -function createTotalRow(section, rows) { - const totalRow = { id: `${section.key}-total`, isTotal: true }; - section.columns.forEach((column, index) => { - if (index === 0) { - totalRow[column.key] = "汇总 Total"; - return; - } - if (column.aggregate === "text") { - totalRow[column.key] = "全部"; - return; - } - totalRow[column.key] = aggregateColumn(rows, column); - }); - return totalRow; -} - -function aggregateLooseValue(rows, card) { - const values = rows.map((row) => row[card.key]).filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))); - if (!values.length) { - return null; - } - if (card.type === "percent") { - return values.reduce((sum, value) => sum + Number(value), 0) / values.length; - } - return values.reduce((sum, value) => sum + Number(value), 0); -} - -function aggregateColumn(rows, column) { - if (column.aggregate === "progress") { - const actual = rows.reduce((sum, row) => sum + Number(row[column.key]?.actual || 0), 0); - const target = rows.reduce((sum, row) => sum + Number(row[column.key]?.target || 0), 0); - return target ? { actual, target } : { actual, noGoal: true, target: 0 }; - } - if (column.aggregate === "ratio") { - return rows.reduce((total, row) => ({ - active: total.active + Number(row[column.key]?.active || 0), - total: total.total + Number(row[column.key]?.total || 0) - }), { active: 0, total: 0 }); - } - if (column.aggregate === "average") { - const values = rows.map((row) => Number(row[column.key])).filter(Number.isFinite); - return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; - } - if (column.aggregate === "flowRate") { - return aggregateFlowRate(rows, column.key); - } - if (column.aggregate === "consumeOutput") { - return aggregateConsumeOutput(rows, column.key); - } - if (column.aggregate === "durationAverage") { - const values = rows.map((row) => Number(row[column.key])).filter(Number.isFinite); - return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; - } - if (column.aggregate === "duration") { - return sumOptionalNumbers(...rows.map((row) => row[column.key])); - } - return sumOptionalNumbers(...rows.map((row) => row[column.key])); -} - -function aggregateFlowRate(rows, key) { - const values = rows.map((row) => row[key]).filter(Boolean); - if (!values.length) { - return null; - } - const totalValue = sumOptionalNumbers(...values.map((value) => value.value)); - const totalProfit = sumOptionalNumbers(...values.map((value) => value.profit)); - const weightedBase = values - .filter((value) => Number.isFinite(Number(value.value)) && Number.isFinite(Number(value.rate)) && Number(value.value) > 0) - .reduce((total, value) => ({ - rateValue: total.rateValue + Number(value.value) * Number(value.rate), - value: total.value + Number(value.value) - }), { rateValue: 0, value: 0 }); - const rateValues = values.map((value) => Number(value.rate)).filter(Number.isFinite); - const rate = weightedBase.value - ? weightedBase.rateValue / weightedBase.value - : (rateValues.length ? rateValues.reduce((sum, value) => sum + value, 0) / rateValues.length : null); - if (totalValue === null && totalProfit === null && rate === null) { - return null; - } - return { profit: totalProfit, rate, value: totalValue }; -} - -function aggregateConsumeOutput(rows, key) { - const values = rows.map((row) => row[key]).filter(Boolean); - if (!values.length) { - return null; - } - const consumed = sumOptionalNumbers(...values.map((value) => value.consumed)); - const output = sumOptionalNumbers(...values.map((value) => value.output)); - const delta = sumOptionalNumbers(...values.map((value) => value.delta)); - const rateValues = values.map((value) => Number(value.rate)).filter(Number.isFinite); - const rate = consumed && output !== null - ? (Number(output) / Number(consumed)) * 100 - : (rateValues.length ? rateValues.reduce((sum, value) => sum + value, 0) / rateValues.length : null); - if (consumed === null && output === null && delta === null && rate === null) { - return null; - } - return { consumed, delta, output, rate }; -} - -function sortRows(rows, columns, sortState) { - const column = columns.find((item) => item.key === sortState.key) || columns[0]; - const direction = sortState.direction === "asc" ? 1 : -1; - return [...rows].sort((left, right) => { - const leftValue = sortComparableValue(left[column.key], column); - const rightValue = sortComparableValue(right[column.key], column); - if (typeof leftValue === "string" || typeof rightValue === "string") { - return collator.compare(String(leftValue), String(rightValue)) * direction; - } - return (Number(leftValue || 0) - Number(rightValue || 0)) * direction; - }); -} - -function sortComparableValue(value, column) { - if (column.type === "progress") { - return value?.target ? Number(value.actual || 0) / Number(value.target) : -1; - } - if (column.type === "ratio") { - return value?.active || 0; - } - if (column.type === "flowRate") { - return value?.value ?? value?.rate ?? -1; - } - if (column.type === "consumeOutput") { - return value?.rate ?? -1; - } - return value; -} - -function selectedOptionsLabel(options, values) { - if (!values?.length || values.includes(ALL_VALUE)) { - return options.find((option) => option.value === ALL_VALUE)?.label || "全部"; - } - const selected = options.filter((option) => values.includes(option.value)); - if (selected.length <= 2) { - return selected.map((option) => option.label).join("、"); - } - return `${selected[0].label} +${selected.length - 1}`; -} - -function selectedCascadeLabel(options, values) { - if (!values?.length || values.includes(ALL_VALUE)) { - return "全部地区"; - } - const flatOptions = options.flatMap((group) => [group, ...group.children]); - const selected = flatOptions.filter((option) => values.includes(option.value)); - if (selected.length <= 2) { - return selected.map((option) => option.label).join("、"); - } - return `${selected[0].label} +${selected.length - 1}`; -} - -function toggleFlatSelection(currentValues, nextValue, options) { - if (nextValue === ALL_VALUE) { - return [ALL_VALUE]; - } - const withoutAll = currentValues.filter((value) => value !== ALL_VALUE); - const next = withoutAll.includes(nextValue) ? withoutAll.filter((value) => value !== nextValue) : [...withoutAll, nextValue]; - const availableValues = options.filter((option) => option.value !== ALL_VALUE).map((option) => option.value); - if (!next.length || availableValues.every((value) => next.includes(value))) { - return [ALL_VALUE]; - } - return next; -} - -function toggleCascadeGroup(currentValues, group) { - const groupValues = [group.value, ...group.children.map((child) => child.value)]; - const withoutAll = currentValues.filter((value) => value !== ALL_VALUE); - const hasGroup = groupValues.every((value) => withoutAll.includes(value)); - const next = hasGroup ? withoutAll.filter((value) => !groupValues.includes(value)) : [...new Set([...withoutAll, ...groupValues])]; - return next.length ? next : [ALL_VALUE]; -} - -function toggleCascadeChild(currentValues, childValue) { - const withoutAll = currentValues.filter((value) => value !== ALL_VALUE); - const next = withoutAll.includes(childValue) ? withoutAll.filter((value) => value !== childValue) : [...withoutAll, childValue]; - return next.length ? next : [ALL_VALUE]; -} - -function formatTypedValue(value, type) { - if (value === null || value === undefined || value === "" || (typeof value === "number" && !Number.isFinite(value))) { - return "--"; - } - if (type === "money") { - return formatMoney(value); - } - if (type === "number") { - return formatNumber(value); - } - if (type === "percent") { - return `${formatDecimal(value)}%`; - } - return String(value ?? "-"); -} - -function formatPercentValue(value) { - if (value === null || value === undefined || value === "" || !Number.isFinite(Number(value))) { - return "--"; - } - return `${formatDecimal(value)}%`; -} - -function formatDuration(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return "--"; - } - const totalSeconds = Math.max(0, Math.round(numeric / 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}小时`; -} - -function formatMoney(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return "--"; - } - const maximumFractionDigits = Math.abs(numeric) < 1000 && !Number.isInteger(numeric) ? 1 : 0; - return `$${numeric.toLocaleString("en-US", { maximumFractionDigits, minimumFractionDigits: maximumFractionDigits })}`; -} - -function formatNumber(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return "--"; - } - return numeric.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatDecimal(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return "--"; - } - return numeric.toLocaleString("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 1 }); -} - -// KpiTargetDialog 按 运营人员×App×区域 编辑当月充值 KPI 目标(美元),保存走 PUT /admin/databi/social/kpi-targets。 -// 月/日目标都填 0 表示清除该条目标。 -function KpiTargetDialog({ kpiData, onClose, onSaved }) { - const periodMonth = kpiData?.period_month || defaultPeriodMonth(); - const [drafts, setDrafts] = useState(() => - (kpiData?.items || []).map((item) => ({ - appCode: item.app_code, - appName: item.app_name || item.app_code, - dailyTargetUsd: minorToUsdInput(item.daily_target_usd_minor), - monthTargetUsd: minorToUsdInput(item.month_target_usd_minor), - operator: item.operator_name || item.operator_account || `#${item.operator_user_id}`, - operatorUserId: item.operator_user_id, - regionId: Number(item.region_id ?? 0), - regionName: item.region_name || "全部区域" - })) - ); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(""); - - const updateDraft = (index, key, value) => { - setDrafts((current) => current.map((draft, draftIndex) => (draftIndex === index ? { ...draft, [key]: value } : draft))); - }; - - const handleSave = async () => { - const items = drafts.map((draft) => ({ - appCode: draft.appCode, - dailyTargetUsdMinor: usdInputToMinor(draft.dailyTargetUsd), - periodMonth, - regionId: draft.regionId, - targetUsdMinor: usdInputToMinor(draft.monthTargetUsd), - userId: draft.operatorUserId - })); - if (items.some((item) => item.targetUsdMinor === null || item.dailyTargetUsdMinor === null)) { - setSaveError("目标金额格式不正确"); - return; - } - setIsSaving(true); - setSaveError(""); - try { - await replaceSocialBiKpiTargets(items); - onSaved(); - } catch (error) { - setSaveError(error.message || "保存失败"); - } finally { - setIsSaving(false); - } - }; - - return ( -
-
event.stopPropagation()} - role="dialog" - > -
- 配置充值 KPI 目标 - {periodMonth} · 按 运营人员 × App × 区域 -
-
- {drafts.length ? ( - - - - - - - - - - - - {drafts.map((draft, index) => ( - - - - - - - - ))} - -
运营人员App区域当月目标 ($)当日目标 ($)
{draft.operator}{draft.appName}{draft.regionName} - updateDraft(index, "monthTargetUsd", event.target.value)} - type="text" - value={draft.monthTargetUsd} - /> - - updateDraft(index, "dailyTargetUsd", event.target.value)} - type="text" - value={draft.dailyTargetUsd} - /> -
- ) : ( -

当前没有可配置的负责范围;请先在「用户管理」里为运营人员分配 App/区域数据范围。

- )} -
-
- {saveError ? {saveError} : null} - - -
-
-
- ); -} - -function defaultPeriodMonth() { - const now = new Date(); - return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}`; -} - -function minorToUsdInput(minor) { - const numeric = Number(minor || 0); - if (!Number.isFinite(numeric) || numeric <= 0) { - return "0"; - } - return String(numeric / 100); -} - -function usdInputToMinor(value) { - const trimmed = String(value ?? "").trim(); - if (trimmed === "") { - return 0; - } - const numeric = Number(trimmed); - if (!Number.isFinite(numeric) || numeric < 0) { - return null; - } - return Math.round(numeric * 100); -} diff --git a/databi/src/data/socialBiDashboard.js b/databi/src/data/socialBiDashboard.js deleted file mode 100644 index 71cc126..0000000 --- a/databi/src/data/socialBiDashboard.js +++ /dev/null @@ -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" }; -} diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx new file mode 100644 index 0000000..0c0e7b2 --- /dev/null +++ b/databi/src/social/SocialBiApp.jsx @@ -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 ( + +
+ +
+ + {data.errors.length ? ( +
+ {data.errors.map((message, index) => ( + {message} + ))} +
+ ) : null} +
+ +
+
+
+
+ ); +} + +function Sidebar({ data, onViewChange, viewKey }) { + const access = data.master?.access; + const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`; + return ( + + ); +} + +function TopBar({ data, filters, updateFilter }) { + return ( +
+
+ + + +
+
+ + + {rangeLabel(data.range)} + {data.isLoading ? : null} +
+
+ ); +} + +function DatePresetControl({ filters, updateFilter }) { + return ( +
+ {DATE_PRESETS.map((preset) => ( + + ))} + {filters.preset === "custom" ? ( + + updateFilter("customStart", event.target.value)} + type="date" + value={filters.customStart} + /> + + updateFilter("customEnd", event.target.value)} + type="date" + value={filters.customEnd} + /> + + ) : null} +
+ ); +} + +function GranularityControl({ filters, updateFilter }) { + return ( +
+ {GRANULARITIES.map((item) => ( + + ))} +
+ ); +} + +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 ( +
+ + {apps.map((app) => { + const selected = !isAll && filters.apps.includes(app.app_code); + return ( + + ); + })} +
+ ); +} + +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 ( +
+ + {isOpen ? ( +
+ + {groups.map((group) => ( +
+ + {group.regions.map((region) => { + const value = `${region.app_code}:${region.region_id}`; + return ( + + ); + })} +
+ ))} + {!groups.length ?

暂无区域目录

: null} +
+ ) : null} +
+ ); +} diff --git a/databi/src/social/format.js b/databi/src/social/format.js new file mode 100644 index 0000000..255397a --- /dev/null +++ b/databi/src/social/format.js @@ -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); +} diff --git a/databi/src/social/metrics.js b/databi/src/social/metrics.js new file mode 100644 index 0000000..dc35ab4 --- /dev/null +++ b/databi/src/social/metrics.js @@ -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]; +} diff --git a/databi/src/social/state.js b/databi/src/social/state.js new file mode 100644 index 0000000..bb41257 --- /dev/null +++ b/databi/src/social/state.js @@ -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:`,单区域 = `:`。 +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; +} diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js new file mode 100644 index 0000000..036df7d --- /dev/null +++ b/databi/src/social/useSocialBiData.js @@ -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; +} diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx new file mode 100644 index 0000000..e4479f8 --- /dev/null +++ b/databi/src/social/views/DataTableView.jsx @@ -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 = ( +
+ {SKELETON_WIDTHS.map((width) => ( + + ))} +
+ ); + } else if (!baseRows.length) { + content = ( +
+ 当前维度暂无数据 + + {dim.dims.includes("region") + ? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度" + : "试试更换日期区间、调整 App 筛选或切换其它维度"} + +
+ ); + } else if (!visibleMetrics.length) { + content = ( +
+ 所有列组已隐藏 + 点击上方列组开关,恢复需要查看的指标列 +
+ ); + } else { + content = ( +
+ + + + {dimColumns.map((column, index) => ( + + ))} + {visibleGroups.map((group) => ( + + ))} + + + {visibleMetrics.map((key) => { + const isSorted = activeSort?.key === key; + return ( + + ); + })} + + + + {totalRow ? ( + + {dimColumns.map((column, index) => ( + + ))} + {visibleMetrics.map((key) => ( + + ))} + + ) : null} + {renderRows.map((row, rowIndex) => ( + + {dimColumns.map((column, index) => ( + + ))} + {visibleMetrics.map((key) => ( + + ))} + + ))} + + {isTruncated ? ( + + + + + + ) : null} +
+ {column.label} + + {group.label} +
handleSort(key)} + > + + {metricLabel(key)} + + {isSorted ? {activeSort.dir === "desc" ? "↓" : "↑"} : null} +
+ {column.totalText} + {formatMetric(key, totalRow[key])}
+ {column.value(row)} + {formatMetric(key, row[key])}
已截断,共 {sortedRows.length} 行,可导出 CSV 查看全部
+
+ ); + } + + return ( +
+
+ 数据明细 + + {rangeLabel(range)} · 共 {sortedRows.length} 行 + +
+
+
+ {DIMENSIONS.map((item) => ( + + ))} +
+
+ 列组 + {METRIC_GROUPS.map((group) => { + const isOpen = !closedGroups.includes(group.key); + return ( + + ); + })} +
+ {hasRestricted ? 已按数据范围过滤 : null} +
+ +
+
+ {content} +
+ ); +} diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx new file mode 100644 index 0000000..7e0ce25 --- /dev/null +++ b/databi/src/social/views/OverviewView.jsx @@ -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}  ${formatChartAmount(valueMetric, numeric)}`; + }); + const header = list[0]?.axisValueLabel || list[0]?.name || ""; + if (list.length > 1) { + lines.push(`合计  ${formatChartAmount(valueMetric, total)}`); + } + return [header, ...lines].filter(Boolean).join("
"); + }, + 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 ( +
+
+ + {metricLabel(metricKey)} + + {!isBlank(deltaValue) ? ( + {formatDeltaRate(deltaValue)} + ) : null} +
+ {formatMetric(metricKey, value)} +
{hasSpark ? : null}
+
+ ); +} + +function OverviewSkeleton() { + return ( +
+
+ {HERO_METRICS.map((metric) => ( +
+ + + +
+ ))} +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ ); +} + +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 ; + } + + return ( +
+ {derived.appErrors.length || hasRestricted ? ( +
+ {derived.appErrors.map((item) => ( + + {item.app_name}: {item.error} + + ))} + {hasRestricted ? 已按你的数据范围过滤 : null} +
+ ) : null} + +
+ {HERO_METRICS.map((metric) => ( + + ))} +
+ +
+
+ {trendLabel}趋势 + 按 App 堆叠 +
+
+ {TREND_METRICS.map((item) => ( + + ))} +
+
+
+
+ {hasTrend ? ( + + ) : ( +
+ 暂无趋势数据 + 当前筛选范围内没有日级数据,试试放宽日期或 App 筛选 +
+ )} +
+
+ +
+
+
+ 渠道构成 + 谷歌 / 三方 / 币商充值 +
+
+ {hasChannelData ? ( + + ) : ( +
+ 暂无渠道拆分数据 + 所选范围内各渠道充值均为空或 0 +
+ )} +
+
+ +
+
+ App 对比 + 按{COMPARE_METRICS.find((item) => item.key === compareMetric)?.label}占比 +
+
+ {COMPARE_METRICS.map((item) => ( + + ))} +
+
+
+
+ {compareRows.length ? ( +
+ {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 ( +
+ + + {row.app_name} + +
+
+
+ {formatMetric(compareMetric, row.metrics[compareMetric])} + +
+ ); + })} +
+ ) : ( +
+ 暂无 App 数据 + 调整顶部 App / 日期筛选后重试 +
+ )} +
+
+
+ +
+
+ 区域 Top5 + 按充值降序 +
+ +
+
+
+ {topRegions.length ? ( +
+ {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 ( +
+ + {row.region_name} + + + {row.app_name} + + +
+
+
+ {formatMetric("recharge_usd_minor", row.recharge_usd_minor)} +
+ ); + })} +
+ ) : ( +
+ 暂无区域数据 + 该范围未配置区域目录,或你没有对应区域的数据权限 +
+ )} +
+
+
+ ); +} diff --git a/databi/src/social/views/RegionsView.jsx b/databi/src/social/views/RegionsView.jsx new file mode 100644 index 0000000..51a802a --- /dev/null +++ b/databi/src/social/views/RegionsView.jsx @@ -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}
暂无数据`; + } + return `${entry.label}
${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 ; + } + + if (!regionTotals.length) { + return ( +
+
+ 暂无区域数据 + 检查顶栏区域筛选,或该 App 尚未配置区域目录(legacy App 在其 Mongo sys_region_config,hyapp 在区域管理) +
+
+ ); + } + + return ( + <> +
+
+ 覆盖区域数 + {formatCount(regionCount)} + 按 App × 区域去重 · 覆盖 {formatCount(appCount)} 个 App +
+
+ 覆盖国家数 + {formatCount(countryCount)} + countryTotals 按国家去重(跨 App 合并) +
+
+ + 区域充值合计 + + {formatMoneyMinor(regionSummary.recharge_usd_minor)} + 当前区间 · 可见区域行合计 +
+
+ +
+
+ 全球分布 + + {countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"} + {mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""} + +
+
+ {MAP_METRICS.map((item) => ( + + ))} +
+
+
+
+ {mapIndex.entries.size ? ( + + ) : ( +
+ 暂无国家数据 + 当前区间没有国家粒度数据,或国家编码未能匹配到世界地图 +
+ )} +
+
+ +
+ {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 ( +
+
+ + {row.region_name} + + {row.restricted ? ( + + 范围裁剪 + + ) : null} + + + {row.app_name} + +
+
+ + {metricLabel("recharge_usd_minor")} + + {formatMoneyMinor(row.recharge_usd_minor)} +
+
+ {CARD_METRICS.map((metric) => ( +
+ + {metricLabel(metric)} + + {formatMetric(metric, row[metric])} +
+ ))} +
+
+ + 占 {row.app_name} 总充值 + {share === null ? "--" : formatRatioPercent(share)} + + + + +
+
+ ); + })} +
+ +
+
+ 区域下钻 + 点击区域行展开国家明细 · 按充值降序 +
+
+ + + + + + {TABLE_METRICS.map((metric) => ( + + ))} + + + + + + + {TABLE_METRICS.map((metric) => ( + + ))} + + {regionRows.map((row) => { + const key = `${row.app_code}:${row.region_id}`; + const isOpen = expandedRegions.has(key); + const children = isOpen ? countriesByRegion.get(key) || [] : []; + return ( + + toggleRegion(key)}> + + + {TABLE_METRICS.map((metric) => ( + + ))} + + {isOpen && !children.length ? ( + + + + ) : null} + {children.map((child) => ( + + + + {TABLE_METRICS.map((metric) => ( + + ))} + + ))} + + ); + })} + +
地区 / 国家App + + {metricLabel(metric)} + +
全部区域{formatCount(appCount)} 个 App{formatMetric(metric, regionSummary[metric])}
+ + + {row.region_name} + {row.restricted ? ( + + 范围裁剪 + + ) : null} + + + + + {row.app_name} + + {formatMetric(metric, row[metric])}
+ 该区域暂无国家明细 +
+ {countryDisplayName(child.country_code, child.country_name)} + {child.app_name}{formatMetric(metric, child[metric])}
+
+
+ + ); +} + +function RegionsSkeleton() { + return ( + <> +
+ {[0, 1, 2].map((index) => ( +
+ + + +
+ ))} +
+
+
+ +
+
+ +
+
+
+ {[0, 1, 2].map((index) => ( +
+ + + +
+ ))} +
+ + ); +} diff --git a/databi/src/social/views/RetentionView.jsx b/databi/src/social/views/RetentionView.jsx new file mode 100644 index 0000000..242730e --- /dev/null +++ b/databi/src/social/views/RetentionView.jsx @@ -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 {formatMetric(key, value)}; + } + if (isBlank(value)) { + return --; + } + 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 ( + + {formatRatioPercent(value)} + + ); +} + +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 ( +
+
+ {RETENTION_SERIES.map(({ key }) => ( +
+ + +
+ ))} +
+
+ +
+
+ +
+
+ +
+
+ ); + } + + if (!hasRows) { + return ( +
+
+
+ 暂无留存数据 + 当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。 +
+
+
+ ); + } + + return ( +
+
+ {RETENTION_SERIES.map(({ key }) => { + const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")]; + return ( +
+ + 整体{metricLabel(key)} + + {formatRatioPercent(summary?.[key])} + + {regionScoped ? "所选区域汇总" : "全部所选 App 汇总"} + {isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`} + +
+ ); + })} +
+ +
+
+ 新增与次日留存 + 柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶 +
+ {comboOption ? ( + + ) : ( +
+ 暂无按日数据 + 当前筛选没有返回按日序列,无法绘制新增与留存趋势。 +
+ )} +
+ +
+
+ 留存趋势 + D1 / D7 / D30 · 按{granularityLabel}分桶 +
+
+ + {appTotals.map((row) => ( + + ))} +
+
+
+ {trendOption ? ( + + ) : ( +
+ 暂无留存趋势数据 + {activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"} +
+ )} +
+ +
+
+ 留存对比 + D1/D7/D30 底色越深留存越高 +
+
+ + +
+
+
+ {tableRows.length ? ( +
+ + + + + {TABLE_METRIC_KEYS.map((key) => ( + + ))} + + + + + + {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))} + + {tableRows.map((item) => ( + + + {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))} + + ))} + +
对象 + + {metricLabel(key)} + +
合计
{item.label}
+
+ ) : ( +
+ 暂无对比数据 + {tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"} +
+ )} +
+ +

+ 留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。 +

+
+ ); +} diff --git a/databi/src/social/views/TeamKpiView.jsx b/databi/src/social/views/TeamKpiView.jsx new file mode 100644 index 0000000..19da75f --- /dev/null +++ b/databi/src/social/views/TeamKpiView.jsx @@ -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 ? : ( +
+
+ 暂无 KPI 数据 + 先在用户管理为运营人员分配 App/区域数据范围 +
+
+ ); + } + + 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 ( +
+
+
+
+ {canViewAll ? "团队 KPI" : "我的 KPI"} + 本月充值目标进度 · {kpi.period_month} + {!canViewAll ? 仅展示我负责的范围 : null} +
+
+
+
+ {attainText} + 本月达成率 +
+
+
+
+
当月充值
+
{formatMoneyMinor(summary.month_recharge_usd_minor)}
+
+
+
月目标
+
{formatMoneyMinor(summary.month_target_usd_minor)}
+
+
+
缺口
+
{heroGapText}
+
+
+
+
+
+ 剩余天数 + {remainingDays} 天 + {kpi.period_month} +
+
+ 需日均 + {heroDailyNeed} + 达成月目标每日还需充值 +
+
+ 区间充值 + {formatMoneyMinor(summary.range_recharge_usd_minor)} + {rangeLabel(range)} +
+
+ +
+
+ 绩效排行 + + {kpi.period_month} · {formatCount(summary.operator_count)} 名运营 + +
+
+ {DIMENSIONS.map((option) => ( + + ))} +
+
+ {SORTS.map((option) => ( + + ))} +
+ {canManage ? ( + + ) : null} +
+
+
+ {dimension === "person" ? ( + + ) : ( + + )} +
+
+ + {isDrawerOpen ? ( + setIsDrawerOpen(false)} + onSaved={() => { + setIsDrawerOpen(false); + refresh(); + }} + /> + ) : null} +
+ ); +} + +function KpiSkeleton() { + return ( +
+
+
+ +
+ + + +
+
+ {[0, 1, 2].map((index) => ( +
+ + + +
+ ))} +
+
+
+ {[0, 1, 2, 3, 4].map((index) => ( + + ))} +
+
+
+ ); +} + +function RankBadge({ rank }) { + const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : ""; + return {rank}; +} + +// 达成率单元格:底槽 + 按达成率填充(超 100% 封顶),数字显示真实值;null 显示"未设目标"。 +function AttainmentBar({ rate }) { + if (isBlank(rate)) { + return 未设目标; + } + const numeric = Number(rate); + const pct = Math.max(0, Math.min(100, numeric * 100)); + return ( + + + ); +} + +function PersonTable({ appCodes, remainingDays, rows }) { + return ( + + + + + + + + + + + + + + + + {rows.map((row, index) => { + const item = row.item; + const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor); + return ( + + + + + + + + + + + + ); + })} + +
名次运营人员App区域 + 区间充值 + + 当月充值 + + 月目标 + + 达成率 + + 需日均 +
+ + +
+ + {operatorLabelOf(item)} + {item.data_error ? ( + + 数据异常 + + ) : null} + + {[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")} +
+
+ + + {item.app_name || item.app_code} + + {regionLabelOf(item)}{formatMoneyMinor(item.range_recharge_usd_minor)}{formatMoneyMinor(item.month_recharge_usd_minor)}{formatMoneyMinor(item.month_target_usd_minor)} + + {dailyNeedText(gap, remainingDays)}
+ ); +} + +function TeamTable({ rows }) { + return ( + + + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + + ))} + +
名次部门 + 人数 + + 区间充值 + + 当月充值 + + 月目标 + + 达成率 +
+ + {row.team}{formatCount(row.memberCount)}{formatMoneyMinor(row.rangeMinor)}{formatMoneyMinor(row.monthMinor)}{formatMoneyMinor(row.targetMinor)} + +
+ ); +} + +// 配置目标抽屉:按 运营人员 × 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 ( +
+ +
+ ); +} diff --git a/databi/src/social/views/data-table-view.css b/databi/src/social/views/data-table-view.css new file mode 100644 index 0000000..bfa79ba --- /dev/null +++ b/databi/src/social/views/data-table-view.css @@ -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; +} diff --git a/databi/src/social/views/overview-view.css b/databi/src/social/views/overview-view.css new file mode 100644 index 0000000..a253e3d --- /dev/null +++ b/databi/src/social/views/overview-view.css @@ -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)); + } +} diff --git a/databi/src/social/views/regions-view.css b/databi/src/social/views/regions-view.css new file mode 100644 index 0000000..eef83b2 --- /dev/null +++ b/databi/src/social/views/regions-view.css @@ -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); +} diff --git a/databi/src/social/views/retention-view.css b/databi/src/social/views/retention-view.css new file mode 100644 index 0000000..598261a --- /dev/null +++ b/databi/src/social/views/retention-view.css @@ -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; + } +} diff --git a/databi/src/social/views/team-kpi-view.css b/databi/src/social/views/team-kpi-view.css new file mode 100644 index 0000000..dd77928 --- /dev/null +++ b/databi/src/social/views/team-kpi-view.css @@ -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); +} diff --git a/databi/src/styles/index.css b/databi/src/styles/index.css index 8fc9adc..749b25b 100644 --- a/databi/src/styles/index.css +++ b/databi/src/styles/index.css @@ -6,5 +6,4 @@ @import "./charts.css"; @import "./tables.css"; @import "./report.css"; -@import "./social-bi.css"; @import "./responsive.css"; diff --git a/databi/src/styles/social-bi.css b/databi/src/styles/social-bi.css deleted file mode 100644 index 64540c7..0000000 --- a/databi/src/styles/social-bi.css +++ /dev/null @@ -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; -} diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css new file mode 100644 index 0000000..c816211 --- /dev/null +++ b/databi/src/styles/social-v2.css @@ -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; + } +} diff --git a/finance/src/api.js b/finance/src/api.js index 5771fc0..033559e 100644 --- a/finance/src/api.js +++ b/finance/src/api.js @@ -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 = {}) { diff --git a/finance/src/components/FinanceOverview.jsx b/finance/src/components/FinanceOverview.jsx index 504641d..4300bd2 100644 --- a/finance/src/components/FinanceOverview.jsx +++ b/finance/src/components/FinanceOverview.jsx @@ -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 ( - + <> + +
+ 工资兑换 + {loading ? "…" : usdtText(data.transferUsdMinor)} + + {loading ? "统计中" : outflowMeta(data.transferUsdMinor, data.transferCount, totalUsd)} + + 用户工资转币商 +
+ ); } +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) { diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index 3f2942f..525de58 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -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);