Show app logos in social BI
This commit is contained in:
parent
85e2e76878
commit
54b584c096
@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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_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 <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}`;
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
|
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/shared/api/generated/schema.d.ts
vendored
32
src/shared/api/generated/schema.d.ts
vendored
@ -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: {
|
||||||
|
|||||||
@ -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();
|
// 主后台使用 camelCase,Social 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(" ")}>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user