2026-07-06 11:47:04 +08:00

323 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// TeamKpiView运营中心视图。后端已按当前登录用户的数据范围裁剪页面只展示可见运营负责范围内的充值数据。
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
import { useMemo, useState } from "react";
import { useSocialBi } from "../SocialBiApp.jsx";
import { appColor } from "../metrics.js";
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
import "./team-kpi-view.css";
const DIMENSIONS = [
{ key: "person", label: "按人员×区域" },
{ key: "operatorApp", label: "按人员×App" },
{ key: "team", label: "按部门" }
];
function addNullable(sum, value) {
if (isBlank(value)) {
return sum;
}
return (sum ?? 0) + Number(value);
}
function numberOr(value, fallback) {
return isBlank(value) ? fallback : Number(value);
}
function compareByMonthRecharge(left, right) {
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
}
function operatorLabelOf(item) {
return item.operator_name || item.operator_account || `#${item.operator_user_id}`;
}
function regionLabelOf(item) {
return item.region_name || item.region_code || "全部区域";
}
function operatorCountOf(items) {
return new Set(items.map((item) => item.operator_user_id).filter((id) => !isBlank(id))).size;
}
export function TeamKpiView() {
const { appCodes, isLoading, kpi } = useSocialBi();
const [dimension, setDimension] = useState("person");
const items = useMemo(() => kpi?.items || [], [kpi]);
const personRows = useMemo(() => {
const rows = items.map((item, index) => ({
item,
key: `${item.operator_user_id}-${item.app_code}-${item.region_id}-${index}`,
monthMinor: item.month_recharge_usd_minor
}));
rows.sort(compareByMonthRecharge);
return rows;
}, [items]);
const operatorAppRows = useMemo(() => {
const rows = (kpi?.operator_app_rows || []).map((item, index) => ({
item,
key: `${item.operator_user_id}-${item.app_code}-${index}`,
monthMinor: item.month_recharge_usd_minor
}));
rows.sort(compareByMonthRecharge);
return rows;
}, [kpi]);
const teamRows = useMemo(() => {
const groups = new Map();
items.forEach((item) => {
const teamName = item.team || "未分组";
let group = groups.get(teamName);
if (!group) {
group = { members: new Set(), monthMinor: null, rangeMinor: null, team: teamName };
groups.set(teamName, group);
}
group.members.add(item.operator_user_id);
group.rangeMinor = addNullable(group.rangeMinor, item.range_recharge_usd_minor);
group.monthMinor = addNullable(group.monthMinor, item.month_recharge_usd_minor);
});
const rows = [...groups.values()].map((group) => ({
memberCount: group.members.size,
monthMinor: group.monthMinor,
rangeMinor: group.rangeMinor,
team: group.team
}));
rows.sort(compareByMonthRecharge);
return rows;
}, [items]);
if (!items.length) {
return isLoading ? (
<KpiSkeleton />
) : (
<section className="sbi-card">
<div className="sbi-empty">
<strong>暂无充值数据</strong>
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
</div>
</section>
);
}
const summary = kpi.summary || {};
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
return (
<div className="sbi-kpi-view">
<section className="sbi-card sbi-kpi-board">
<div className="sbi-card-header">
<strong>充值排行</strong>
<small>
{kpi.period_month} · {formatCount(operatorCount)} 名运营
</small>
<div className="sbi-card-toolbar">
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
{DIMENSIONS.map((option) => (
<button
aria-checked={dimension === option.key}
className={dimension === option.key ? "is-active" : ""}
key={option.key}
onClick={() => setDimension(option.key)}
role="radio"
type="button"
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? (
<PersonTable appCodes={appCodes} rows={personRows} />
) : dimension === "operatorApp" ? (
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
) : (
<TeamTable rows={teamRows} />
)}
</div>
</section>
</div>
);
}
function KpiSkeleton() {
return (
<div aria-busy="true" className="sbi-kpi-view">
<section className="sbi-card">
<div className="sbi-kpi-table-skeleton">
{[0, 1, 2, 3, 4, 5].map((index) => (
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
))}
</div>
</section>
</div>
);
}
function RankBadge({ rank }) {
const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : "";
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
}
function OperatorAppTable({ appCodes, rows }) {
return (
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">名次</th>
<th className="is-left">运营人员</th>
<th className="is-left">App</th>
<th className="is-left">负责区域</th>
<th>
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计USD">
区间充值
</span>
</th>
<th>
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计USD">
当月充值
</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const item = row.item;
return (
<tr key={row.key}>
<td className="is-left">
<RankBadge rank={index + 1} />
</td>
<td className="is-left">
<OperatorCell item={item} />
</td>
<td className="is-left">
<AppCell appCodes={appCodes} 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>
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
</tr>
);
})}
</tbody>
</table>
);
}
function PersonTable({ appCodes, rows }) {
return (
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">名次</th>
<th className="is-left">运营人员</th>
<th className="is-left">App</th>
<th className="is-left">区域</th>
<th>
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计USD">
区间充值
</span>
</th>
<th>
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计USD">
当月充值
</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const item = row.item;
return (
<tr key={row.key}>
<td className="is-left">
<RankBadge rank={index + 1} />
</td>
<td className="is-left">
<OperatorCell item={item} />
</td>
<td className="is-left">
<AppCell appCodes={appCodes} item={item} />
</td>
<td className="is-left">{regionLabelOf(item)}</td>
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
</tr>
);
})}
</tbody>
</table>
);
}
function TeamTable({ rows }) {
return (
<table className="sbi-table">
<thead>
<tr>
<th className="is-left">名次</th>
<th className="is-left">部门</th>
<th>
<span className="sbi-metric-hint" title="部门内去重运营人数">
人数
</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门内区间充值合计USD">
区间充值
</span>
</th>
<th>
<span className="sbi-metric-hint" title="部门内本月充值合计USD">
当月充值
</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.team}>
<td className="is-left">
<RankBadge rank={index + 1} />
</td>
<td className="is-left">{row.team}</td>
<td>{formatCount(row.memberCount)}</td>
<td>{formatMoneyMinor(row.rangeMinor)}</td>
<td>{formatMoneyMinor(row.monthMinor)}</td>
</tr>
))}
</tbody>
</table>
);
}
function OperatorCell({ item }) {
return (
<div className="sbi-kpi-person">
<span className="sbi-kpi-person-top">
<strong>{operatorLabelOf(item)}</strong>
{item.data_error ? (
<span className="sbi-badge is-warn" title={item.data_error}>
数据异常
</span>
) : null}
</span>
<small>{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}</small>
</div>
);
}
function AppCell({ appCodes, 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}
</span>
);
}