Show app logos in social BI

This commit is contained in:
zhx 2026-07-14 14:22:24 +08:00
parent 85e2e76878
commit 54b584c096
17 changed files with 175 additions and 73 deletions

View File

@ -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": [
{

View File

@ -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);

View File

@ -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_nameLogo 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 <AppIdentity app={catalogApp || app} appCode={resolvedCode} appName={appName} className={className} size={size} />;
}
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"
>
<span className="sbi-chip-dot" style={{ background: appColor(app.app_code, allCodes) }} />
{app.app_name || app.app_code}
<AppIdentity app={app} size="small" />
</button>
);
})}
@ -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} · 全部区域
<AppIdentity app={group.app} size="small" />
<span>全部区域</span>
</button>
{group.regions.map((region) => {
const value = `${region.app_code}:${region.region_id}`;

View File

@ -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() {
<tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{column.value(row)}
{column.key === "app" ? (
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
) : column.value(row)}
</td>
))}
{visibleMetrics.map((key) => (
@ -784,7 +786,9 @@ export function DataTableView() {
{requirementRows.map((row, rowIndex) => (
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
<td className="is-left">
<SocialAppIdentity appCode={row.app_code} appName={requirementAppName(row)} />
</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}
@ -823,7 +827,9 @@ export function DataTableView() {
>
<td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
<td className="is-left">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
<td className="is-left">
<SocialAppIdentity appCode={row.app_code} appName={requirementAppName(row)} />
</td>
{requirementGameData.columns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}

View File

@ -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() {
</div>
<div className="sbi-funnel-apps">
{appRows.map((app) => (
<span key={app.app_code}>{app.app_name || app.app_code}</span>
<SocialAppIdentity appCode={app.app_code} appName={app.app_name} key={app.app_code} />
))}
</div>
</div>

View File

@ -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 (
<div className="sbi-ov-compare-row" key={row.app_code}>
<span className="sbi-ov-compare-name">
<span className="sbi-ov-dot" style={{ background: color }} />
{row.app_name}
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</span>
<div className="sbi-ov-bar-track">
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
@ -464,8 +463,7 @@ export function OverviewView() {
<span className="sbi-ov-top-name">
<strong>{row.region_name}</strong>
<span className="sbi-badge">
<span className="sbi-ov-dot" style={{ background: color }} />
{row.app_name}
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</span>
</span>
<div className="sbi-ov-bar-track">

View File

@ -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() {
</span>
) : null}
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</span>
</div>
<div className="sbi-rg-region-money">
@ -394,8 +393,7 @@ export function RegionsView() {
</td>
<td className="is-left">
<span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
{row.app_name}
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</span>
</td>
{TABLE_METRICS.map((metric) => (
@ -414,7 +412,9 @@ export function RegionsView() {
<td className="is-left">
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
</td>
<td className="is-left sbi-rg-child-app">{child.app_name}</td>
<td className="is-left sbi-rg-child-app">
<SocialAppIdentity appCode={child.app_code} appName={child.app_name} />
</td>
{TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, child[metric])}</td>
))}

View File

@ -2,7 +2,7 @@
// / AppApp/ 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}
<SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</button>
))}
</div>
@ -377,7 +376,10 @@ export function RetentionView() {
</tr>
{tableRows.map((item) => (
<tr key={item.key}>
<td className="is-left">{item.label}</td>
<td className="is-left">
<SocialAppIdentity appCode={item.row.app_code} appName={item.row.app_name} />
{tableDim === "region" ? <span> · {item.row.region_name}</span> : null}
</td>
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
</tr>
))}

View File

@ -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() {
</div>
<div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? (
<PersonTable appCodes={appCodes} rows={personRows} />
<PersonTable rows={personRows} />
) : dimension === "operatorApp" ? (
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
<OperatorAppTable rows={operatorAppRows} />
) : (
<TeamTable rows={teamRows} />
)}
@ -163,7 +162,7 @@ function RankBadge({ rank }) {
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
}
function OperatorAppTable({ appCodes, rows }) {
function OperatorAppTable({ rows }) {
return (
<table className="sbi-table">
<thead>
@ -196,7 +195,7 @@ function OperatorAppTable({ appCodes, rows }) {
<OperatorCell item={item} />
</td>
<td className="is-left">
<AppCell appCodes={appCodes} item={item} />
<AppCell item={item} />
</td>
<td className="is-left">{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}</td>
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
@ -209,7 +208,7 @@ function OperatorAppTable({ appCodes, rows }) {
);
}
function PersonTable({ appCodes, rows }) {
function PersonTable({ rows }) {
return (
<table className="sbi-table">
<thead>
@ -242,7 +241,7 @@ function PersonTable({ appCodes, rows }) {
<OperatorCell item={item} />
</td>
<td className="is-left">
<AppCell appCodes={appCodes} item={item} />
<AppCell item={item} />
</td>
<td className="is-left">{regionLabelOf(item)}</td>
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
@ -312,11 +311,10 @@ function OperatorCell({ item }) {
);
}
function AppCell({ appCodes, item }) {
function AppCell({ item }) {
return (
<span className="sbi-kpi-app">
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} />
{item.app_name || item.app_code}
<SocialAppIdentity appCode={item.app_code} appName={item.app_name} />
</span>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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: {

View File

@ -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();
// 使 camelCaseSocial 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 (
<span className={[styles.root, styles[size] || styles.medium, className].filter(Boolean).join(" ")}>

View File

@ -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;

View File

@ -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(
<AppIdentity app={{ app_code: "aslan", app_name: "Aslan", logo_url: "https://media.example.com/aslan.png" }} />,
);
expect(screen.getByText("Aslan")).toBeInTheDocument();
expect(container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/aslan.png");
});