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", "x-permission": "overview:view",
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/StatisticsObjectResponse" "$ref": "#/components/responses/SocialBiMasterResponse"
} }
}, },
"x-permissions": ["overview:view"] "x-permissions": ["overview:view"]
@ -7978,6 +7978,16 @@
} }
} }
}, },
"SocialBiMasterResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseSocialBiMaster"
}
}
}
},
"SocialBiRequirementsResponse": { "SocialBiRequirementsResponse": {
"description": "OK", "description": "OK",
"content": { "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": { "ApiResponseSocialBiRequirements": {
"allOf": [ "allOf": [
{ {

View File

@ -263,8 +263,8 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
fetchSocialBiMaster.mockResolvedValue({ fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] }, access: { all: true, scopes: [] },
apps: [ apps: [
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }, { app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" } { app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
], ],
operators: [ operators: [
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 } { 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 Hero 12,300 + 5,000 USD = $173
// App Lalu App $123 Top5 // App Lalu App $123 Top5
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0); 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.getAllByText("充值 ($)").length).toBeGreaterThan(0);
expect(screen.getByText("付费用户")).toBeTruthy(); expect(screen.getByText("付费用户")).toBeTruthy();
expect(screen.getAllByText("$173").length).toBeGreaterThan(0); expect(screen.getAllByText("$173").length).toBeGreaterThan(0);

View File

@ -20,7 +20,7 @@ import {
writeStateToURL writeStateToURL
} from "./state.js"; } from "./state.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; 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 { SOCIAL_BI_FUNNEL_APP_CODES, useSocialBiData } from "./useSocialBiData.js";
import { OverviewView } from "./views/OverviewView.jsx"; import { OverviewView } from "./views/OverviewView.jsx";
import { RegionsView } from "./views/RegionsView.jsx"; import { RegionsView } from "./views/RegionsView.jsx";
@ -49,6 +49,14 @@ export function useSocialBi() {
return context; 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() { export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []); const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters); 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))} onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
type="button" type="button"
> >
<span className="sbi-chip-dot" style={{ background: appColor(app.app_code, allCodes) }} /> <AppIdentity app={app} size="small" />
{app.app_name || app.app_code}
</button> </button>
); );
})} })}
@ -316,7 +323,8 @@ function RegionSelect({ data, filters, updateFilter }) {
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))} onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
type="button" type="button"
> >
{group.app.app_name || group.app.app_code} · 全部区域 <AppIdentity app={group.app} size="small" />
<span>全部区域</span>
</button> </button>
{group.regions.map((region) => { {group.regions.map((region) => {
const value = `${region.app_code}:${region.region_id}`; const value = `${region.app_code}:${region.region_id}`;

View File

@ -4,7 +4,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined"; import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import { useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { import {
METRIC_GROUPS, METRIC_GROUPS,
aggregateMetricMaps, aggregateMetricMaps,
@ -25,10 +25,10 @@ const EMPTY_ROWS = [];
// value totalText """" // value totalText """"
const DIM_COLUMNS = { const DIM_COLUMNS = {
app: { label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" }, app: { key: "app", label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
country: { label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" }, country: { key: "country", label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
day: { label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" }, day: { key: "day", label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
region: { label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" } region: { key: "region", label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
}; };
// key derived // key derived
@ -702,7 +702,9 @@ export function DataTableView() {
<tr key={rowIdentity(row, dim.key, rowIndex)}> <tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => ( {dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}> <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> </td>
))} ))}
{visibleMetrics.map((key) => ( {visibleMetrics.map((key) => (
@ -784,7 +786,9 @@ export function DataTableView() {
{requirementRows.map((row, rowIndex) => ( {requirementRows.map((row, rowIndex) => (
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}> <tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td> <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) => ( {requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td> <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 sbi-dt-sticky">{requirementGameLabel(row)}</td>
<td className="is-left">{row.stat_day || "--"}</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) => ( {requirementGameData.columns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td> <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 { EChart } from "../../charts/EChart.jsx";
import { formatCount, formatRatioPercent, isBlank } from "../format.js"; import { formatCount, formatRatioPercent, isBlank } from "../format.js";
import { rangeLabel } from "../state.js"; import { rangeLabel } from "../state.js";
import { useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import "./funnel-view.css"; import "./funnel-view.css";
const COHORT_DIMENSIONS = [ const COHORT_DIMENSIONS = [
@ -87,7 +87,7 @@ export function FunnelView() {
</div> </div>
<div className="sbi-funnel-apps"> <div className="sbi-funnel-apps">
{appRows.map((app) => ( {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>
</div> </div>

View File

@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { EChart } from "../../charts/EChart.jsx"; import { EChart } from "../../charts/EChart.jsx";
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js"; import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.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 { bucketDay } from "../state.js";
import "./overview-view.css"; import "./overview-view.css";
@ -411,8 +411,7 @@ export function OverviewView() {
return ( return (
<div className="sbi-ov-compare-row" key={row.app_code}> <div className="sbi-ov-compare-row" key={row.app_code}>
<span className="sbi-ov-compare-name"> <span className="sbi-ov-compare-name">
<span className="sbi-ov-dot" style={{ background: color }} /> <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
{row.app_name}
</span> </span>
<div className="sbi-ov-bar-track"> <div className="sbi-ov-bar-track">
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} /> <div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
@ -464,8 +463,7 @@ export function OverviewView() {
<span className="sbi-ov-top-name"> <span className="sbi-ov-top-name">
<strong>{row.region_name}</strong> <strong>{row.region_name}</strong>
<span className="sbi-badge"> <span className="sbi-badge">
<span className="sbi-ov-dot" style={{ background: color }} /> <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
{row.app_name}
</span> </span>
</span> </span>
<div className="sbi-ov-bar-track"> <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 { resolveCountryMeta } from "../../data/countryMeta.js";
import worldMap from "../../data/world.json"; import worldMap from "../../data/world.json";
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js"; import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import "./regions-view.css"; import "./regions-view.css";
// EChart.jsx visualMap echarts.use // EChart.jsx visualMap echarts.use
@ -69,7 +69,7 @@ function byRechargeDesc(a, b) {
} }
export function RegionsView() { export function RegionsView() {
const { appCodes, derived, isLoading } = useSocialBi(); const { derived, isLoading } = useSocialBi();
const { appTotals, countryTotals, regionTotals } = derived; const { appTotals, countryTotals, regionTotals } = derived;
const [mapMetric, setMapMetric] = useState("recharge_usd_minor"); const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
const [expandedRegions, setExpandedRegions] = useState(() => new Set()); const [expandedRegions, setExpandedRegions] = useState(() => new Set());
@ -307,8 +307,7 @@ export function RegionsView() {
</span> </span>
) : null} ) : null}
<span className="sbi-rg-app-tag"> <span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} /> <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
{row.app_name}
</span> </span>
</div> </div>
<div className="sbi-rg-region-money"> <div className="sbi-rg-region-money">
@ -394,8 +393,7 @@ export function RegionsView() {
</td> </td>
<td className="is-left"> <td className="is-left">
<span className="sbi-rg-app-tag"> <span className="sbi-rg-app-tag">
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} /> <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
{row.app_name}
</span> </span>
</td> </td>
{TABLE_METRICS.map((metric) => ( {TABLE_METRICS.map((metric) => (
@ -414,7 +412,9 @@ export function RegionsView() {
<td className="is-left"> <td className="is-left">
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span> <span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
</td> </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) => ( {TABLE_METRICS.map((metric) => (
<td key={metric}>{formatMetric(metric, child[metric])}</td> <td key={metric}>{formatMetric(metric, child[metric])}</td>
))} ))}

View File

@ -2,7 +2,7 @@
// / AppApp/ SocialBiApp.jsx useSocialBi() // / AppApp/ SocialBiApp.jsx useSocialBi()
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { EChart } from "../../charts/EChart.jsx"; import { EChart } from "../../charts/EChart.jsx";
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js"; import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js";
@ -204,7 +204,6 @@ export function RetentionView() {
return tableSource return tableSource
.map((row) => ({ .map((row) => ({
key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code, 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 row
})) }))
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0)); .sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
@ -312,7 +311,7 @@ export function RetentionView() {
role="radio" role="radio"
type="button" type="button"
> >
{row.app_name} <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
</button> </button>
))} ))}
</div> </div>
@ -377,7 +376,10 @@ export function RetentionView() {
</tr> </tr>
{tableRows.map((item) => ( {tableRows.map((item) => (
<tr key={item.key}> <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]))} {TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
</tr> </tr>
))} ))}

View File

@ -2,8 +2,7 @@
// USD "--" 0 // USD "--" 0
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { appColor } from "../metrics.js";
import { formatCount, formatMoneyMinor, isBlank } from "../format.js"; import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
import "./team-kpi-view.css"; import "./team-kpi-view.css";
@ -41,7 +40,7 @@ function operatorCountOf(items) {
} }
export function TeamKpiView() { export function TeamKpiView() {
const { appCodes, isLoading, kpi } = useSocialBi(); const { isLoading, kpi } = useSocialBi();
const [dimension, setDimension] = useState("person"); const [dimension, setDimension] = useState("person");
const items = useMemo(() => kpi?.items || [], [kpi]); const items = useMemo(() => kpi?.items || [], [kpi]);
@ -132,9 +131,9 @@ export function TeamKpiView() {
</div> </div>
<div className="sbi-table-scroll sbi-kpi-board-scroll"> <div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? ( {dimension === "person" ? (
<PersonTable appCodes={appCodes} rows={personRows} /> <PersonTable rows={personRows} />
) : dimension === "operatorApp" ? ( ) : dimension === "operatorApp" ? (
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} /> <OperatorAppTable rows={operatorAppRows} />
) : ( ) : (
<TeamTable rows={teamRows} /> <TeamTable rows={teamRows} />
)} )}
@ -163,7 +162,7 @@ function RankBadge({ rank }) {
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>; return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
} }
function OperatorAppTable({ appCodes, rows }) { function OperatorAppTable({ rows }) {
return ( return (
<table className="sbi-table"> <table className="sbi-table">
<thead> <thead>
@ -196,7 +195,7 @@ function OperatorAppTable({ appCodes, rows }) {
<OperatorCell item={item} /> <OperatorCell item={item} />
</td> </td>
<td className="is-left"> <td className="is-left">
<AppCell appCodes={appCodes} item={item} /> <AppCell item={item} />
</td> </td>
<td className="is-left">{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}</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> <td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
@ -209,7 +208,7 @@ function OperatorAppTable({ appCodes, rows }) {
); );
} }
function PersonTable({ appCodes, rows }) { function PersonTable({ rows }) {
return ( return (
<table className="sbi-table"> <table className="sbi-table">
<thead> <thead>
@ -242,7 +241,7 @@ function PersonTable({ appCodes, rows }) {
<OperatorCell item={item} /> <OperatorCell item={item} />
</td> </td>
<td className="is-left"> <td className="is-left">
<AppCell appCodes={appCodes} item={item} /> <AppCell item={item} />
</td> </td>
<td className="is-left">{regionLabelOf(item)}</td> <td className="is-left">{regionLabelOf(item)}</td>
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td> <td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
@ -312,11 +311,10 @@ function OperatorCell({ item }) {
); );
} }
function AppCell({ appCodes, item }) { function AppCell({ item }) {
return ( return (
<span className="sbi-kpi-app"> <span className="sbi-kpi-app">
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} /> <SocialAppIdentity appCode={item.app_code} appName={item.app_name} />
{item.app_name || item.app_code}
</span> </span>
); );
} }

View File

@ -32,7 +32,7 @@
gap: 8px; gap: 8px;
} }
.sbi-funnel-apps span { .sbi-funnel-apps > span {
padding: 5px 10px; padding: 5px 10px;
border: 1px solid var(--sbi-border); border: 1px solid var(--sbi-border);
border-radius: 999px; border-radius: 999px;

View File

@ -101,13 +101,6 @@
margin-left: auto; margin-left: auto;
} }
.sbi-rg-app-dot {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
}
.sbi-rg-region-money { .sbi-rg-region-money {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -62,13 +62,6 @@
gap: 7px; gap: 7px;
} }
.sbi-kpi-app-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.sbi-kpi-table-skeleton { .sbi-kpi-table-skeleton {
display: grid; display: grid;
gap: 14px; gap: 14px;

View File

@ -265,12 +265,6 @@
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1); 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 { .sbi-region-select {
position: relative; position: relative;
} }
@ -331,6 +325,9 @@
} }
.sbi-region-option.is-group { .sbi-region-option.is-group {
display: flex;
align-items: center;
gap: 8px;
color: var(--sbi-text-2); color: var(--sbi-text-2);
font-weight: 700; font-weight: 700;
} }

View File

@ -4961,6 +4961,27 @@ export interface components {
[key: string]: unknown; [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"] & { ApiResponseSocialBiRequirements: components["schemas"]["Envelope"] & {
data?: components["schemas"]["SocialBiRequirementsResult"]; data?: components["schemas"]["SocialBiRequirementsResult"];
}; };
@ -6313,6 +6334,15 @@ export interface components {
}; };
}; };
/** @description OK */ /** @description OK */
SocialBiMasterResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseSocialBiMaster"];
};
};
/** @description OK */
SocialBiRequirementsResponse: { SocialBiRequirementsResponse: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@ -9456,7 +9486,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: components["responses"]["StatisticsObjectResponse"]; 200: components["responses"]["SocialBiMasterResponse"];
}; };
}; };
getSocialBiOverview: { getSocialBiOverview: {

View File

@ -3,9 +3,10 @@ import styles from "./AppIdentity.module.css";
// AppIdentity App Logo // AppIdentity App Logo
export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) { export function AppIdentity({ app, appCode, appName, className = "", logoUrl, size = "medium" }) {
const resolvedCode = String(app?.appCode || appCode || "").trim(); // 使 camelCaseSocial BI 使 snake_case
const resolvedName = String(app?.appName || appName || resolvedCode || "-").trim(); const resolvedCode = String(app?.appCode || app?.app_code || appCode || "").trim();
const resolvedLogoUrl = String(app?.logoUrl || logoUrl || "").trim(); const resolvedName = String(app?.appName || app?.app_name || appName || resolvedCode || "-").trim();
const resolvedLogoUrl = String(app?.logoUrl || app?.logo_url || logoUrl || "").trim();
return ( return (
<span className={[styles.root, styles[size] || styles.medium, className].filter(Boolean).join(" ")}> <span className={[styles.root, styles[size] || styles.medium, className].filter(Boolean).join(" ")}>

View File

@ -8,7 +8,7 @@
.logo, .logo,
.fallback { .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; border-radius: 7px;
box-shadow: 0 1px 3px rgb(15 23 42 / 10%); box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
flex: 0 0 auto; flex: 0 0 auto;
@ -18,7 +18,7 @@
.fallback { .fallback {
align-items: center; align-items: center;
background: linear-gradient(145deg, #e8f1ff, #f7faff); background: linear-gradient(145deg, #e8f1ff, #f7faff);
color: var(--primary); color: var(--primary, #2557d6);
display: inline-flex; display: inline-flex;
font-size: 11px; font-size: 11px;
font-weight: 800; 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(container.querySelector("img")).toBeNull();
expect(screen.getByText("L", { selector: "span" })).toBeInTheDocument(); 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");
});