diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index b6edec9..8141587 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -4022,7 +4022,7 @@
"x-permission": "overview:view",
"responses": {
"200": {
- "$ref": "#/components/responses/StatisticsObjectResponse"
+ "$ref": "#/components/responses/SocialBiMasterResponse"
}
},
"x-permissions": ["overview:view"]
@@ -7978,6 +7978,16 @@
}
}
},
+ "SocialBiMasterResponse": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseSocialBiMaster"
+ }
+ }
+ }
+ },
"SocialBiRequirementsResponse": {
"description": "OK",
"content": {
@@ -9626,6 +9636,61 @@
}
]
},
+ "ApiResponseSocialBiMaster": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Envelope"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/SocialBiMasterResult"
+ }
+ }
+ }
+ ]
+ },
+ "SocialBiMasterResult": {
+ "type": "object",
+ "properties": {
+ "apps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SocialBiMasterApp"
+ }
+ },
+ "regions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "operators": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "access": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "required": ["apps", "regions", "operators", "access"]
+ },
+ "SocialBiMasterApp": {
+ "type": "object",
+ "properties": {
+ "app_code": { "type": "string" },
+ "app_name": { "type": "string" },
+ "logo_url": { "type": "string" },
+ "kind": { "type": "string" }
+ },
+ "required": ["app_code", "app_name", "logo_url", "kind"]
+ },
"ApiResponseSocialBiRequirements": {
"allOf": [
{
diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx
index 3bfe215..de036c0 100644
--- a/databi/src/DatabiApp.test.jsx
+++ b/databi/src/DatabiApp.test.jsx
@@ -263,8 +263,8 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
- { app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
- { app_code: "aslan", app_name: "Aslan", kind: "legacy" }
+ { app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" },
+ { app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
],
operators: [
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 }
@@ -340,6 +340,8 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
// App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
+ expect(document.querySelector('img[src="https://media.example.com/lalu.png"]')).toBeTruthy();
+ expect(document.querySelector('img[src="https://media.example.com/aslan.png"]')).toBeTruthy();
expect(screen.getAllByText("充值 ($)").length).toBeGreaterThan(0);
expect(screen.getByText("付费用户")).toBeTruthy();
expect(screen.getAllByText("$173").length).toBeGreaterThan(0);
diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx
index 1bf3dec..04a7de3 100644
--- a/databi/src/social/SocialBiApp.jsx
+++ b/databi/src/social/SocialBiApp.jsx
@@ -20,7 +20,7 @@ import {
writeStateToURL
} from "./state.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
-import { appColor } from "./metrics.js";
+import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { SOCIAL_BI_FUNNEL_APP_CODES, useSocialBiData } from "./useSocialBiData.js";
import { OverviewView } from "./views/OverviewView.jsx";
import { RegionsView } from "./views/RegionsView.jsx";
@@ -49,6 +49,14 @@ export function useSocialBi() {
return context;
}
+// Social BI 的统计结果只携带 app_code/app_name;Logo 从 master 目录按 app_code 回填,保证所有视图使用同一份品牌事实。
+export function SocialAppIdentity({ app, appCode, appName, className = "", size = "small" }) {
+ const context = useSocialBi();
+ const resolvedCode = String(app?.app_code || app?.appCode || appCode || "").trim().toLowerCase();
+ const catalogApp = (context.master?.apps || []).find((item) => String(item.app_code || item.appCode || "").trim().toLowerCase() === resolvedCode);
+ return ;
+}
+
export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters);
@@ -261,8 +269,7 @@ function AppChips({ data, filters, updateFilter, viewKey }) {
onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
type="button"
>
-
- {app.app_name || app.app_code}
+
);
})}
@@ -316,7 +323,8 @@ function RegionSelect({ data, filters, updateFilter }) {
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
type="button"
>
- {group.app.app_name || group.app.app_code} · 全部区域
+
+ 全部区域
{group.regions.map((region) => {
const value = `${region.app_code}:${region.region_id}`;
diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx
index d146c64..5506f14 100644
--- a/databi/src/social/views/DataTableView.jsx
+++ b/databi/src/social/views/DataTableView.jsx
@@ -4,7 +4,7 @@
import { useEffect, useMemo, useState } from "react";
import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
-import { useSocialBi } from "../SocialBiApp.jsx";
+import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import {
METRIC_GROUPS,
aggregateMetricMaps,
@@ -25,10 +25,10 @@ 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 || "--" }
+ app: { key: "app", label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
+ country: { key: "country", label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
+ day: { key: "day", label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
+ region: { key: "region", label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
};
// 五种明细维度:key 与 derived 的行集字段一一对应。
@@ -702,7 +702,9 @@ export function DataTableView() {
{dimColumns.map((column, index) => (
|
- {column.value(row)}
+ {column.key === "app" ? (
+
+ ) : column.value(row)}
|
))}
{visibleMetrics.map((key) => (
@@ -784,7 +786,9 @@ export function DataTableView() {
{requirementRows.map((row, rowIndex) => (
| {row.stat_day || "--"} |
- {requirementAppName(row)} |
+
+
+ |
{requirementColumns.map((column) => (
{formatRequirementValue(column, row[column.key])} |
))}
@@ -823,7 +827,9 @@ export function DataTableView() {
>
{requirementGameLabel(row)} |
{row.stat_day || "--"} |
- {requirementAppName(row)} |
+
+
+ |
{requirementGameData.columns.map((column) => (
{formatRequirementValue(column, row[column.key])} |
))}
diff --git a/databi/src/social/views/FunnelView.jsx b/databi/src/social/views/FunnelView.jsx
index 25d2d0d..0c9b0ac 100644
--- a/databi/src/social/views/FunnelView.jsx
+++ b/databi/src/social/views/FunnelView.jsx
@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { EChart } from "../../charts/EChart.jsx";
import { formatCount, formatRatioPercent, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
-import { useSocialBi } from "../SocialBiApp.jsx";
+import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import "./funnel-view.css";
const COHORT_DIMENSIONS = [
@@ -87,7 +87,7 @@ export function FunnelView() {
{appRows.map((app) => (
- {app.app_name || app.app_code}
+
))}
diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx
index 7e0ce25..68d76db 100644
--- a/databi/src/social/views/OverviewView.jsx
+++ b/databi/src/social/views/OverviewView.jsx
@@ -5,7 +5,7 @@ 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 { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { bucketDay } from "../state.js";
import "./overview-view.css";
@@ -411,8 +411,7 @@ export function OverviewView() {
return (
-
- {row.app_name}
+
@@ -464,8 +463,7 @@ export function OverviewView() {
{row.region_name}
-
- {row.app_name}
+
diff --git a/databi/src/social/views/RegionsView.jsx b/databi/src/social/views/RegionsView.jsx
index 51a802a..ccba927 100644
--- a/databi/src/social/views/RegionsView.jsx
+++ b/databi/src/social/views/RegionsView.jsx
@@ -8,8 +8,8 @@ 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 { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
+import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import "./regions-view.css";
// EChart.jsx 未注册 visualMap 组件,这里按需补注册(echarts.use 幂等,不影响其它图表)。
@@ -69,7 +69,7 @@ function byRechargeDesc(a, b) {
}
export function RegionsView() {
- const { appCodes, derived, isLoading } = useSocialBi();
+ const { derived, isLoading } = useSocialBi();
const { appTotals, countryTotals, regionTotals } = derived;
const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
const [expandedRegions, setExpandedRegions] = useState(() => new Set());
@@ -307,8 +307,7 @@ export function RegionsView() {
) : null}
-
- {row.app_name}
+
@@ -394,8 +393,7 @@ export function RegionsView() {
-
- {row.app_name}
+
|
{TABLE_METRICS.map((metric) => (
@@ -414,7 +412,9 @@ export function RegionsView() {
{countryDisplayName(child.country_code, child.country_name)}
|
- {child.app_name} |
+
+
+ |
{TABLE_METRICS.map((metric) => (
{formatMetric(metric, child[metric])} |
))}
diff --git a/databi/src/social/views/RetentionView.jsx b/databi/src/social/views/RetentionView.jsx
index 242730e..bca9ba3 100644
--- a/databi/src/social/views/RetentionView.jsx
+++ b/databi/src/social/views/RetentionView.jsx
@@ -2,7 +2,7 @@
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
import { useMemo, useState } from "react";
-import { useSocialBi } from "../SocialBiApp.jsx";
+import { SocialAppIdentity, 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";
@@ -204,7 +204,6 @@ export function RetentionView() {
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));
@@ -312,7 +311,7 @@ export function RetentionView() {
role="radio"
type="button"
>
- {row.app_name}
+
))}
@@ -377,7 +376,10 @@ export function RetentionView() {
{tableRows.map((item) => (
- | {item.label} |
+
+
+ {tableDim === "region" ? · {item.row.region_name} : null}
+ |
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
))}
diff --git a/databi/src/social/views/TeamKpiView.jsx b/databi/src/social/views/TeamKpiView.jsx
index 73c8c45..348d81a 100644
--- a/databi/src/social/views/TeamKpiView.jsx
+++ b/databi/src/social/views/TeamKpiView.jsx
@@ -2,8 +2,7 @@
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
import { useMemo, useState } from "react";
-import { useSocialBi } from "../SocialBiApp.jsx";
-import { appColor } from "../metrics.js";
+import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
import "./team-kpi-view.css";
@@ -41,7 +40,7 @@ function operatorCountOf(items) {
}
export function TeamKpiView() {
- const { appCodes, isLoading, kpi } = useSocialBi();
+ const { isLoading, kpi } = useSocialBi();
const [dimension, setDimension] = useState("person");
const items = useMemo(() => kpi?.items || [], [kpi]);
@@ -132,9 +131,9 @@ export function TeamKpiView() {
{dimension === "person" ? (
-
+
) : dimension === "operatorApp" ? (
-
+
) : (
)}
@@ -163,7 +162,7 @@ function RankBadge({ rank }) {
return
{rank};
}
-function OperatorAppTable({ appCodes, rows }) {
+function OperatorAppTable({ rows }) {
return (
@@ -196,7 +195,7 @@ function OperatorAppTable({ appCodes, rows }) {
|
-
+
|
{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`} |
{formatMoneyMinor(item.range_recharge_usd_minor)} |
@@ -209,7 +208,7 @@ function OperatorAppTable({ appCodes, rows }) {
);
}
-function PersonTable({ appCodes, rows }) {
+function PersonTable({ rows }) {
return (
@@ -242,7 +241,7 @@ function PersonTable({ appCodes, rows }) {
|
-
+
|
{regionLabelOf(item)} |
{formatMoneyMinor(item.range_recharge_usd_minor)} |
@@ -312,11 +311,10 @@ function OperatorCell({ item }) {
);
}
-function AppCell({ appCodes, item }) {
+function AppCell({ item }) {
return (
-
- {item.app_name || item.app_code}
+
);
}
diff --git a/databi/src/social/views/funnel-view.css b/databi/src/social/views/funnel-view.css
index c376d4b..e22c013 100644
--- a/databi/src/social/views/funnel-view.css
+++ b/databi/src/social/views/funnel-view.css
@@ -32,7 +32,7 @@
gap: 8px;
}
-.sbi-funnel-apps span {
+.sbi-funnel-apps > span {
padding: 5px 10px;
border: 1px solid var(--sbi-border);
border-radius: 999px;
diff --git a/databi/src/social/views/regions-view.css b/databi/src/social/views/regions-view.css
index eef83b2..2ae744b 100644
--- a/databi/src/social/views/regions-view.css
+++ b/databi/src/social/views/regions-view.css
@@ -101,13 +101,6 @@
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;
diff --git a/databi/src/social/views/team-kpi-view.css b/databi/src/social/views/team-kpi-view.css
index 3859ef0..be703f6 100644
--- a/databi/src/social/views/team-kpi-view.css
+++ b/databi/src/social/views/team-kpi-view.css
@@ -62,13 +62,6 @@
gap: 7px;
}
-.sbi-kpi-app-dot {
- flex: none;
- width: 8px;
- height: 8px;
- border-radius: 50%;
-}
-
.sbi-kpi-table-skeleton {
display: grid;
gap: 14px;
diff --git a/databi/src/styles/social-v2.css b/databi/src/styles/social-v2.css
index d6b13f5..ff19fdc 100644
--- a/databi/src/styles/social-v2.css
+++ b/databi/src/styles/social-v2.css
@@ -265,12 +265,6 @@
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;
}
@@ -331,6 +325,9 @@
}
.sbi-region-option.is-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
color: var(--sbi-text-2);
font-weight: 700;
}
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 3e49a93..db4ad97 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -4961,6 +4961,27 @@ export interface components {
[key: string]: unknown;
};
};
+ ApiResponseSocialBiMaster: components["schemas"]["Envelope"] & {
+ data?: components["schemas"]["SocialBiMasterResult"];
+ };
+ SocialBiMasterResult: {
+ apps: components["schemas"]["SocialBiMasterApp"][];
+ regions: {
+ [key: string]: unknown;
+ }[];
+ operators: {
+ [key: string]: unknown;
+ }[];
+ access: {
+ [key: string]: unknown;
+ };
+ };
+ SocialBiMasterApp: {
+ app_code: string;
+ app_name: string;
+ logo_url: string;
+ kind: string;
+ };
ApiResponseSocialBiRequirements: components["schemas"]["Envelope"] & {
data?: components["schemas"]["SocialBiRequirementsResult"];
};
@@ -6313,6 +6334,15 @@ export interface components {
};
};
/** @description OK */
+ SocialBiMasterResponse: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ApiResponseSocialBiMaster"];
+ };
+ };
+ /** @description OK */
SocialBiRequirementsResponse: {
headers: {
[name: string]: unknown;
@@ -9456,7 +9486,7 @@ export interface operations {
};
requestBody?: never;
responses: {
- 200: components["responses"]["StatisticsObjectResponse"];
+ 200: components["responses"]["SocialBiMasterResponse"];
};
};
getSocialBiOverview: {
diff --git a/src/shared/ui/AppIdentity.jsx b/src/shared/ui/AppIdentity.jsx
index 48d8cf0..475f98a 100644
--- a/src/shared/ui/AppIdentity.jsx
+++ b/src/shared/ui/AppIdentity.jsx
@@ -3,9 +3,10 @@ import styles from "./AppIdentity.module.css";
// AppIdentity 是后台中 App 的唯一展示形式:业务页只传目录数据,避免各个下拉、表格和弹窗分别维护 Logo 规则。
export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) {
- const resolvedCode = String(app?.appCode || appCode || "").trim();
- const resolvedName = String(app?.appName || appName || resolvedCode || "-").trim();
- const resolvedLogoUrl = String(app?.logoUrl || logoUrl || "").trim();
+ // 主后台使用 camelCase,Social BI 的服务端聚合契约使用 snake_case;组件在展示边界统一兼容,业务视图不复制字段转换。
+ const resolvedCode = String(app?.appCode || app?.app_code || appCode || "").trim();
+ const resolvedName = String(app?.appName || app?.app_name || appName || resolvedCode || "-").trim();
+ const resolvedLogoUrl = String(app?.logoUrl || app?.logo_url || logoUrl || "").trim();
return (
diff --git a/src/shared/ui/AppIdentity.module.css b/src/shared/ui/AppIdentity.module.css
index fa188f4..abb2fac 100644
--- a/src/shared/ui/AppIdentity.module.css
+++ b/src/shared/ui/AppIdentity.module.css
@@ -8,7 +8,7 @@
.logo,
.fallback {
- border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
+ border: 1px solid color-mix(in srgb, var(--border, #d9e2ef) 80%, transparent);
border-radius: 7px;
box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
flex: 0 0 auto;
@@ -18,7 +18,7 @@
.fallback {
align-items: center;
background: linear-gradient(145deg, #e8f1ff, #f7faff);
- color: var(--primary);
+ color: var(--primary, #2557d6);
display: inline-flex;
font-size: 11px;
font-weight: 800;
diff --git a/src/shared/ui/AppIdentity.test.jsx b/src/shared/ui/AppIdentity.test.jsx
index f2b7de8..913afce 100644
--- a/src/shared/ui/AppIdentity.test.jsx
+++ b/src/shared/ui/AppIdentity.test.jsx
@@ -20,3 +20,12 @@ test("falls back to a stable initial when the logo cannot load", () => {
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("L", { selector: "span" })).toBeInTheDocument();
});
+
+test("supports the snake case Social BI app contract", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByText("Aslan")).toBeInTheDocument();
+ expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/aslan.png");
+});