From 54b584c0962afe123e9d496c9fbae54be6a6c86f Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 14 Jul 2026 14:22:24 +0800 Subject: [PATCH] Show app logos in social BI --- contracts/admin-openapi.json | 67 ++++++++++++++++++++++- databi/src/DatabiApp.test.jsx | 6 +- databi/src/social/SocialBiApp.jsx | 16 ++++-- databi/src/social/views/DataTableView.jsx | 22 +++++--- databi/src/social/views/FunnelView.jsx | 4 +- databi/src/social/views/OverviewView.jsx | 8 +-- databi/src/social/views/RegionsView.jsx | 16 +++--- databi/src/social/views/RetentionView.jsx | 10 ++-- databi/src/social/views/TeamKpiView.jsx | 22 ++++---- databi/src/social/views/funnel-view.css | 2 +- databi/src/social/views/regions-view.css | 7 --- databi/src/social/views/team-kpi-view.css | 7 --- databi/src/styles/social-v2.css | 9 +-- src/shared/api/generated/schema.d.ts | 32 ++++++++++- src/shared/ui/AppIdentity.jsx | 7 ++- src/shared/ui/AppIdentity.module.css | 4 +- src/shared/ui/AppIdentity.test.jsx | 9 +++ 17 files changed, 175 insertions(+), 73 deletions(-) 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 }) { @@ -209,7 +208,7 @@ function OperatorAppTable({ appCodes, rows }) { ); } -function PersonTable({ appCodes, rows }) { +function PersonTable({ rows }) { return (
- + {item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`} {formatMoneyMinor(item.range_recharge_usd_minor)}
@@ -242,7 +241,7 @@ function PersonTable({ appCodes, rows }) { @@ -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"); +});
- + {regionLabelOf(item)} {formatMoneyMinor(item.range_recharge_usd_minor)}