去掉kpi相关
This commit is contained in:
parent
4874bba9ac
commit
46654ac23f
@ -3287,70 +3287,6 @@
|
||||
"x-permissions": ["overview:view"]
|
||||
}
|
||||
},
|
||||
"/admin/databi/social/kpi-targets": {
|
||||
"get": {
|
||||
"operationId": "listSocialBiKpiTargets",
|
||||
"x-permission": "overview:view",
|
||||
"parameters": [
|
||||
{ "in": "query", "name": "period_month", "schema": { "type": "string" } },
|
||||
{ "in": "query", "name": "user_id", "schema": { "type": "integer", "format": "int64" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
||||
}
|
||||
},
|
||||
"x-permissions": ["overview:view"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "replaceSocialBiKpiTargets",
|
||||
"x-permission": "databi-kpi:manage",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "integer", "format": "int64" },
|
||||
"appCode": { "type": "string" },
|
||||
"regionId": { "type": "integer", "format": "int64" },
|
||||
"periodMonth": { "type": "string" },
|
||||
"targetUsdMinor": { "type": "integer", "format": "int64" },
|
||||
"dailyTargetUsdMinor": { "type": "integer", "format": "int64" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"appItems": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appCode": { "type": "string" },
|
||||
"periodMonth": { "type": "string" },
|
||||
"targetUsdMinor": { "type": "integer", "format": "int64" },
|
||||
"dailyTargetUsdMinor": { "type": "integer", "format": "int64" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/StatisticsObjectResponse"
|
||||
}
|
||||
},
|
||||
"x-permissions": ["databi-kpi:manage"]
|
||||
}
|
||||
},
|
||||
"/admin/finance/scope": {
|
||||
"get": {
|
||||
"operationId": "getFinanceScope",
|
||||
|
||||
@ -16,10 +16,8 @@ vi.mock("./api.js", () => ({
|
||||
fetchSocialBiFilterOptions: vi.fn(),
|
||||
fetchSocialBiFunnel: vi.fn(),
|
||||
fetchSocialBiKpi: vi.fn(),
|
||||
fetchSocialBiKpiTargets: vi.fn(),
|
||||
fetchSocialBiMaster: vi.fn(),
|
||||
fetchSocialBiOverview: vi.fn(),
|
||||
replaceSocialBiKpiTargets: vi.fn(),
|
||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||
fetchStatisticsOverview: vi.fn(),
|
||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||
@ -54,7 +52,7 @@ beforeEach(() => {
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [],
|
||||
operators: [],
|
||||
permissions: { kpi_manage: false, kpi_view_all: true },
|
||||
permissions: {},
|
||||
regions: []
|
||||
});
|
||||
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||
@ -267,7 +265,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
||||
operators: [
|
||||
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 }
|
||||
],
|
||||
permissions: { kpi_manage: true, kpi_view_all: true },
|
||||
permissions: {},
|
||||
regions: [{ app_code: "aslan", countries: ["SA"], region_code: "MENA", region_id: 11, region_name: "中东大区" }]
|
||||
});
|
||||
fetchSocialBiOverview.mockResolvedValue({
|
||||
@ -302,10 +300,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
||||
{
|
||||
app_code: "aslan",
|
||||
app_name: "Aslan",
|
||||
daily_target_usd_minor: 0,
|
||||
month_attainment_rate: 0.9,
|
||||
month_recharge_usd_minor: 90_000,
|
||||
month_target_usd_minor: 100_000,
|
||||
operator_account: "omar",
|
||||
operator_name: "Omar",
|
||||
operator_user_id: 9,
|
||||
@ -318,7 +313,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
||||
}
|
||||
],
|
||||
period_month: "2026-06",
|
||||
summary: { month_recharge_usd_minor: 90_000, month_target_usd_minor: 100_000, operator_count: 1, range_recharge_usd_minor: 5000 }
|
||||
summary: { month_recharge_usd_minor: 90_000, operator_count: 1, range_recharge_usd_minor: 5000 }
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
@ -356,14 +351,13 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
|
||||
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
|
||||
|
||||
// 侧栏切换:运营中心 —— 运营人员 Omar 上榜、达成率 90.0%、kpi_manage 时有配置目标入口。
|
||||
// 侧栏切换:运营中心 —— 运营人员 Omar 上榜,只展示负责范围内的充值数据。
|
||||
await act(async () => {
|
||||
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
|
||||
});
|
||||
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("90.0%").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: "配置目标" })).toBeTruthy();
|
||||
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
||||
@ -378,7 +372,7 @@ test("renders social BI funnel view and limits funnel apps to supported apps", a
|
||||
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
|
||||
],
|
||||
operators: [],
|
||||
permissions: { kpi_manage: false, kpi_view_all: true },
|
||||
permissions: {},
|
||||
regions: []
|
||||
});
|
||||
fetchSocialBiFunnel.mockResolvedValue({
|
||||
|
||||
@ -223,27 +223,6 @@ export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, period
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiKpi), { query });
|
||||
}
|
||||
|
||||
export async function fetchSocialBiKpiTargets({ periodMonth, userId } = {}) {
|
||||
const query = {};
|
||||
if (periodMonth) {
|
||||
query.period_month = periodMonth;
|
||||
}
|
||||
if (userId) {
|
||||
query.user_id = String(userId);
|
||||
}
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.listSocialBiKpiTargets), { query });
|
||||
}
|
||||
|
||||
// payload 支持 {items, appItems}:items=人级目标(regionId=0 表示该运营的整 App 目标),
|
||||
// appItems=App 级总目标(不分运营);传数组时兼容旧调用视为 items。
|
||||
export async function replaceSocialBiKpiTargets(payload) {
|
||||
const body = Array.isArray(payload) ? { items: payload } : payload;
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.replaceSocialBiKpiTargets), {
|
||||
body,
|
||||
method: "PUT"
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDatabiData(path, { appCode, body, method, query } = {}) {
|
||||
const headers = requestHeaders(appCode);
|
||||
const init = {
|
||||
|
||||
@ -112,7 +112,7 @@ export function useSocialBiData(filters) {
|
||||
});
|
||||
} else {
|
||||
setKpi(null);
|
||||
nextErrors.push(`充值目标数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
|
||||
nextErrors.push(`运营充值数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
setIsLoading(false);
|
||||
|
||||
@ -1,59 +1,18 @@
|
||||
// TeamKpiView:运营中心视图。kpi_view_all 用户看团队排行,普通运营看"我的充值"。
|
||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi():金额为 USD 分、比例为 0-1 小数、缺失为 null(显示 "--")。
|
||||
// TeamKpiView:运营中心视图。后端已按当前登录用户的数据范围裁剪,页面只展示可见运营负责范围内的充值数据。
|
||||
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { replaceSocialBiKpiTargets } from "../../api.js";
|
||||
import { appColor } from "../metrics.js";
|
||||
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
|
||||
import { rangeLabel } from "../state.js";
|
||||
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
||||
import "./team-kpi-view.css";
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
const DIMENSIONS = [
|
||||
{ key: "person", label: "按人员×区域" },
|
||||
{ key: "operatorApp", label: "按人员×App" },
|
||||
{ key: "team", label: "按部门" }
|
||||
];
|
||||
|
||||
const SORTS = [
|
||||
{ key: "rate", label: "按达成率" },
|
||||
{ key: "month", label: "按当月充值" }
|
||||
];
|
||||
|
||||
// 剩余天数:当月结束时间与当前时间的差值向上取整,最小 0。
|
||||
function remainingDaysOf(monthEndMs) {
|
||||
if (isBlank(monthEndMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.ceil((Number(monthEndMs) - Date.now()) / DAY_MS));
|
||||
}
|
||||
|
||||
// 缺口(USD 分):月目标 - 当月充值;无目标或当月充值缺失时返回 null(不可当 0 计算)。
|
||||
function gapMinorOf(targetMinor, monthMinor) {
|
||||
if (isBlank(targetMinor) || Number(targetMinor) <= 0 || isBlank(monthMinor)) {
|
||||
return null;
|
||||
}
|
||||
return Number(targetMinor) - Number(monthMinor);
|
||||
}
|
||||
|
||||
// 行内"需日均":缺口 / 剩余天数;已达成显示"已达成",算不出来显示 "--"。
|
||||
function dailyNeedText(gapMinor, remainingDays) {
|
||||
if (gapMinor === null) {
|
||||
return "--";
|
||||
}
|
||||
if (gapMinor <= 0) {
|
||||
return "已达成";
|
||||
}
|
||||
if (!remainingDays) {
|
||||
return "--";
|
||||
}
|
||||
return formatMoneyMinor(Math.round(gapMinor / remainingDays));
|
||||
}
|
||||
|
||||
// 可空求和:全部缺失保持 null,避免把缺失口径当 0。
|
||||
function addNullable(sum, value) {
|
||||
if (isBlank(value)) {
|
||||
return sum;
|
||||
@ -65,14 +24,7 @@ function numberOr(value, fallback) {
|
||||
return isBlank(value) ? fallback : Number(value);
|
||||
}
|
||||
|
||||
// 排序:按达成率(缺失排最后),并以当月充值作为次级排序;或直接按当月充值。
|
||||
function compareRows(left, right, sortKey) {
|
||||
if (sortKey === "rate") {
|
||||
const diff = numberOr(right.rate, -1) - numberOr(left.rate, -1);
|
||||
if (diff) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
function compareByMonthRecharge(left, right) {
|
||||
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
|
||||
}
|
||||
|
||||
@ -84,52 +36,35 @@ function regionLabelOf(item) {
|
||||
return item.region_name || item.region_code || "全部区域";
|
||||
}
|
||||
|
||||
function usdInputText(minor) {
|
||||
if (isBlank(minor)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Number(minor) / 100);
|
||||
}
|
||||
|
||||
// 美元输入 → USD 分;空串视为 0(清除目标),NaN/负数返回 null 表示非法。
|
||||
function usdInputToMinor(text) {
|
||||
const numeric = Number(String(text).trim());
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(numeric * 100);
|
||||
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, master, range, refresh } = useSocialBi();
|
||||
const { appCodes, isLoading, kpi } = useSocialBi();
|
||||
const [dimension, setDimension] = useState("person");
|
||||
const [sortKey, setSortKey] = useState("rate");
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
const items = useMemo(() => kpi?.items || [], [kpi]);
|
||||
const appRows = useMemo(() => kpi?.app_rows || [], [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,
|
||||
rate: item.month_attainment_rate
|
||||
monthMinor: item.month_recharge_usd_minor
|
||||
}));
|
||||
rows.sort((left, right) => compareRows(left, right, sortKey));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [items, sortKey]);
|
||||
}, [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,
|
||||
rate: item.month_attainment_rate
|
||||
monthMinor: item.month_recharge_usd_minor
|
||||
}));
|
||||
rows.sort((left, right) => compareRows(left, right, sortKey));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [kpi, sortKey]);
|
||||
}, [kpi]);
|
||||
|
||||
const teamRows = useMemo(() => {
|
||||
const groups = new Map();
|
||||
@ -137,28 +72,27 @@ export function TeamKpiView() {
|
||||
const teamName = item.team || "未分组";
|
||||
let group = groups.get(teamName);
|
||||
if (!group) {
|
||||
group = { members: new Set(), monthMinor: null, rangeMinor: null, targetMinor: null, team: teamName };
|
||||
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);
|
||||
group.targetMinor = addNullable(group.targetMinor, item.month_target_usd_minor);
|
||||
});
|
||||
const rows = [...groups.values()].map((group) => ({
|
||||
memberCount: group.members.size,
|
||||
monthMinor: group.monthMinor,
|
||||
rangeMinor: group.rangeMinor,
|
||||
rate: group.targetMinor && group.monthMinor !== null ? group.monthMinor / group.targetMinor : null,
|
||||
targetMinor: group.targetMinor,
|
||||
team: group.team
|
||||
}));
|
||||
rows.sort((left, right) => compareRows(left, right, sortKey));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [items, sortKey]);
|
||||
}, [items]);
|
||||
|
||||
if (!items.length) {
|
||||
return isLoading ? <KpiSkeleton /> : (
|
||||
return isLoading ? (
|
||||
<KpiSkeleton />
|
||||
) : (
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无充值数据</strong>
|
||||
@ -169,79 +103,15 @@ export function TeamKpiView() {
|
||||
}
|
||||
|
||||
const summary = kpi.summary || {};
|
||||
const canViewAll = master?.permissions?.kpi_view_all !== false;
|
||||
const canManage = Boolean(master?.permissions?.kpi_manage);
|
||||
const remainingDays = remainingDaysOf(kpi.month_end_ms);
|
||||
|
||||
const heroRate = summary.month_attainment_rate;
|
||||
const attainText = isBlank(heroRate) ? "未设目标" : formatRatioPercent(heroRate);
|
||||
const ringPct = isBlank(heroRate) ? 0 : Math.max(0, Math.min(100, Number(heroRate) * 100));
|
||||
const heroGap = gapMinorOf(summary.month_target_usd_minor, summary.month_recharge_usd_minor);
|
||||
const heroGapText = heroGap === null ? "--" : heroGap <= 0 ? "已达成" : formatMoneyMinor(heroGap);
|
||||
const heroDailyNeed =
|
||||
heroGap !== null && heroGap > 0 && remainingDays > 0 ? formatMoneyMinor(Math.round(heroGap / remainingDays)) : "--";
|
||||
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
|
||||
|
||||
return (
|
||||
<div className="sbi-kpi-view">
|
||||
<section className="sbi-kpi-hero">
|
||||
<article className="sbi-card sbi-kpi-hero-main">
|
||||
<div className="sbi-card-header">
|
||||
<strong>{canViewAll ? "团队充值" : "我的充值"}</strong>
|
||||
<small>本月充值目标进度 · {kpi.period_month}</small>
|
||||
{!canViewAll ? <span className="sbi-badge">仅展示我负责的范围</span> : null}
|
||||
</div>
|
||||
<div className="sbi-kpi-hero-body">
|
||||
<div
|
||||
aria-label={`本月达成率 ${attainText}`}
|
||||
className="sbi-kpi-ring"
|
||||
role="img"
|
||||
style={{ "--sbi-kpi-pct": String(ringPct) }}
|
||||
>
|
||||
<div className="sbi-kpi-ring-hole">
|
||||
<strong className={isBlank(heroRate) ? "is-muted" : undefined}>{attainText}</strong>
|
||||
<small>本月达成率</small>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="sbi-kpi-hero-stats">
|
||||
<div>
|
||||
<dt>当月充值</dt>
|
||||
<dd>{formatMoneyMinor(summary.month_recharge_usd_minor)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>月目标</dt>
|
||||
<dd>{formatMoneyMinor(summary.month_target_usd_minor)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="sbi-metric-hint" title="月目标 - 当月充值">缺口</dt>
|
||||
<dd>{heroGapText}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
<article className="sbi-card sbi-kpi-mini">
|
||||
<span className="sbi-kpi-mini-label">剩余天数</span>
|
||||
<strong className="sbi-kpi-mini-value">{remainingDays} 天</strong>
|
||||
<small className="sbi-kpi-mini-note">{kpi.period_month}</small>
|
||||
</article>
|
||||
<article className="sbi-card sbi-kpi-mini">
|
||||
<span className="sbi-kpi-mini-label sbi-metric-hint" title="缺口 / 剩余天数">需日均</span>
|
||||
<strong className="sbi-kpi-mini-value">{heroDailyNeed}</strong>
|
||||
<small className="sbi-kpi-mini-note">达成月目标每日还需充值</small>
|
||||
</article>
|
||||
<article className="sbi-card sbi-kpi-mini">
|
||||
<span className="sbi-kpi-mini-label">区间充值</span>
|
||||
<strong className="sbi-kpi-mini-value">{formatMoneyMinor(summary.range_recharge_usd_minor)}</strong>
|
||||
<small className="sbi-kpi-mini-note">{rangeLabel(range)}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{appRows.length ? <AppTargetStrip appCodes={appCodes} appRows={appRows} remainingDays={remainingDays} /> : null}
|
||||
|
||||
<section className="sbi-card sbi-kpi-board">
|
||||
<div className="sbi-card-header">
|
||||
<strong>充值排行</strong>
|
||||
<small>
|
||||
{kpi.period_month} · {formatCount(summary.operator_count)} 名运营
|
||||
{kpi.period_month} · {formatCount(operatorCount)} 名运营
|
||||
</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
||||
@ -258,49 +128,18 @@ export function TeamKpiView() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div aria-label="排序方式" className="sbi-seg" role="radiogroup">
|
||||
{SORTS.map((option) => (
|
||||
<button
|
||||
aria-checked={sortKey === option.key}
|
||||
className={sortKey === option.key ? "is-active" : ""}
|
||||
key={option.key}
|
||||
onClick={() => setSortKey(option.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Button onClick={() => setIsDrawerOpen(true)} size="small" variant="outlined">
|
||||
配置目标
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
||||
{dimension === "person" ? (
|
||||
<PersonTable appCodes={appCodes} remainingDays={remainingDays} rows={personRows} />
|
||||
<PersonTable appCodes={appCodes} rows={personRows} />
|
||||
) : dimension === "operatorApp" ? (
|
||||
<OperatorAppTable appCodes={appCodes} remainingDays={remainingDays} rows={operatorAppRows} />
|
||||
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
|
||||
) : (
|
||||
<TeamTable rows={teamRows} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isDrawerOpen ? (
|
||||
<KpiTargetDrawer
|
||||
appCodes={appCodes}
|
||||
kpi={kpi}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
onSaved={() => {
|
||||
setIsDrawerOpen(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -308,26 +147,9 @@ export function TeamKpiView() {
|
||||
function KpiSkeleton() {
|
||||
return (
|
||||
<div aria-busy="true" className="sbi-kpi-view">
|
||||
<section className="sbi-kpi-hero">
|
||||
<article className="sbi-card sbi-kpi-hero-loading">
|
||||
<span className="sbi-skeleton sbi-kpi-skeleton-ring" />
|
||||
<div className="sbi-kpi-skeleton-lines">
|
||||
<span className="sbi-skeleton" style={{ height: 16, width: "72%" }} />
|
||||
<span className="sbi-skeleton" style={{ height: 16, width: "56%" }} />
|
||||
<span className="sbi-skeleton" style={{ height: 16, width: "64%" }} />
|
||||
</div>
|
||||
</article>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<article className="sbi-card sbi-kpi-mini" key={index}>
|
||||
<span className="sbi-skeleton" style={{ height: 12, width: 64 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 24, width: 112 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 10, width: 88 }} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-kpi-table-skeleton">
|
||||
{[0, 1, 2, 3, 4].map((index) => (
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
|
||||
))}
|
||||
</div>
|
||||
@ -341,64 +163,7 @@ function RankBadge({ rank }) {
|
||||
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
|
||||
}
|
||||
|
||||
// 达成率单元格:底槽 + 按达成率填充(超 100% 封顶),数字显示真实值;null 显示"未设目标"。
|
||||
function AttainmentBar({ rate }) {
|
||||
if (isBlank(rate)) {
|
||||
return <span className="sbi-kpi-no-target">未设目标</span>;
|
||||
}
|
||||
const numeric = Number(rate);
|
||||
const pct = Math.max(0, Math.min(100, numeric * 100));
|
||||
return (
|
||||
<span className="sbi-kpi-attain">
|
||||
<span aria-hidden="true" className="sbi-kpi-bar">
|
||||
<span className={numeric >= 1 ? "sbi-kpi-bar-fill is-done" : "sbi-kpi-bar-fill"} style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="sbi-kpi-attain-value">{formatRatioPercent(rate)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// App 总目标进度条:不分运营的全员共同目标(kpi.app_rows)。
|
||||
function AppTargetStrip({ appCodes, appRows, remainingDays }) {
|
||||
return (
|
||||
<section aria-label="App 总目标进度" className="sbi-kpi-app-strip">
|
||||
{appRows.map((row) => {
|
||||
const gap = gapMinorOf(row.month_target_usd_minor, row.month_recharge_usd_minor);
|
||||
return (
|
||||
<article className="sbi-card sbi-kpi-app-card" key={row.app_code}>
|
||||
<div className="sbi-kpi-app-card-head">
|
||||
<span className="sbi-kpi-app">
|
||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
||||
<strong>{row.app_name || row.app_code}</strong>
|
||||
</span>
|
||||
<span className="sbi-badge" title="不分运营的全员共同目标">App 总目标</span>
|
||||
{row.data_error ? (
|
||||
<span className="sbi-badge is-warn" title={row.data_error}>
|
||||
数据异常
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sbi-kpi-app-card-amounts">
|
||||
<strong>{formatMoneyMinor(row.month_recharge_usd_minor)}</strong>
|
||||
<small> / 目标 {row.month_target_usd_minor > 0 ? formatMoneyMinor(row.month_target_usd_minor) : "未设"}</small>
|
||||
</div>
|
||||
<AttainmentBar rate={row.month_attainment_rate} />
|
||||
<small className="sbi-kpi-app-card-note">
|
||||
{[
|
||||
`${formatCount(row.operator_count)} 名运营`,
|
||||
`区间充值 ${formatMoneyMinor(row.range_recharge_usd_minor)}`,
|
||||
`需日均 ${dailyNeedText(gap, remainingDays)}`
|
||||
].join(" · ")}
|
||||
</small>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 人员×App 小计表:该运营在此 App 下全部负责区域的合计与其 App 级目标。
|
||||
function OperatorAppTable({ appCodes, remainingDays, rows }) {
|
||||
function OperatorAppTable({ appCodes, rows }) {
|
||||
return (
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
@ -408,60 +173,34 @@ function OperatorAppTable({ appCodes, remainingDays, rows }) {
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">负责区域</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计(USD)">区间充值</span>
|
||||
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计(USD)">当月充值</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="该运营在此 App 的月度总目标(USD)">App 月目标</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="当月充值 / App 月目标">达成率</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="缺口 / 剩余天数">需日均</span>
|
||||
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const item = row.item;
|
||||
const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor);
|
||||
return (
|
||||
<tr key={row.key}>
|
||||
<td className="is-left">
|
||||
<RankBadge rank={index + 1} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<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>
|
||||
<OperatorCell item={item} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<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>
|
||||
</td>
|
||||
<td className="is-left">
|
||||
{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}
|
||||
<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>
|
||||
<td>{formatMoneyMinor(item.month_target_usd_minor)}</td>
|
||||
<td>
|
||||
<AttainmentBar rate={item.month_attainment_rate} />
|
||||
</td>
|
||||
<td>{dailyNeedText(gap, remainingDays)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@ -470,7 +209,7 @@ function OperatorAppTable({ appCodes, remainingDays, rows }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PersonTable({ appCodes, remainingDays, rows }) {
|
||||
function PersonTable({ appCodes, rows }) {
|
||||
return (
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
@ -480,58 +219,34 @@ function PersonTable({ appCodes, remainingDays, rows }) {
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">区域</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计(USD)">区间充值</span>
|
||||
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计(USD)">当月充值</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="本月充值目标(USD),由管理员配置">月目标</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="当月充值 / 月目标">达成率</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="缺口 / 剩余天数,达成月目标每日还需充值">需日均</span>
|
||||
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const item = row.item;
|
||||
const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor);
|
||||
return (
|
||||
<tr key={row.key}>
|
||||
<td className="is-left">
|
||||
<RankBadge rank={index + 1} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<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>
|
||||
<OperatorCell item={item} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<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>
|
||||
<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>
|
||||
<td>{formatMoneyMinor(item.month_target_usd_minor)}</td>
|
||||
<td>
|
||||
<AttainmentBar rate={item.month_attainment_rate} />
|
||||
</td>
|
||||
<td>{dailyNeedText(gap, remainingDays)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@ -548,19 +263,19 @@ function TeamTable({ rows }) {
|
||||
<th className="is-left">名次</th>
|
||||
<th className="is-left">部门</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内去重运营人数">人数</span>
|
||||
<span className="sbi-metric-hint" title="部门内去重运营人数">
|
||||
人数
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内区间充值合计(USD)">区间充值</span>
|
||||
<span className="sbi-metric-hint" title="部门内区间充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内本月充值合计(USD)">当月充值</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内月目标合计(USD)">月目标</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门当月充值合计 / 月目标合计">达成率</span>
|
||||
<span className="sbi-metric-hint" title="部门内本月充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -574,10 +289,6 @@ function TeamTable({ rows }) {
|
||||
<td>{formatCount(row.memberCount)}</td>
|
||||
<td>{formatMoneyMinor(row.rangeMinor)}</td>
|
||||
<td>{formatMoneyMinor(row.monthMinor)}</td>
|
||||
<td>{formatMoneyMinor(row.targetMinor)}</td>
|
||||
<td>
|
||||
<AttainmentBar rate={row.rate} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -585,299 +296,27 @@ function TeamTable({ rows }) {
|
||||
);
|
||||
}
|
||||
|
||||
function draftInvalid(draft) {
|
||||
return usdInputToMinor(draft.monthText) === null || usdInputToMinor(draft.dailyText) === null;
|
||||
}
|
||||
|
||||
function TargetInputCell({ draft, label, onChange }) {
|
||||
function OperatorCell({ item }) {
|
||||
return (
|
||||
<input
|
||||
aria-label={label}
|
||||
className={usdInputToMinor(draft.value) === null ? "sbi-kpi-input is-invalid" : "sbi-kpi-input"}
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
onChange={onChange}
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={draft.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 配置目标抽屉:三级目标——
|
||||
// ① App 总目标(不分运营的全员共同目标) ② 运营×App 总目标(该运营在此 App 的总盘子)
|
||||
// ③ 运营×App×区域 明细目标。月/日目标同时填 0 表示清除该条。
|
||||
function KpiTargetDrawer({ appCodes, kpi, onClose, onSaved }) {
|
||||
const [appDrafts, setAppDrafts] = useState(() =>
|
||||
(kpi.app_rows || []).map((row) => ({
|
||||
appCode: row.app_code,
|
||||
appName: row.app_name || row.app_code,
|
||||
dailyText: usdInputText(row.daily_target_usd_minor),
|
||||
monthText: usdInputText(row.month_target_usd_minor)
|
||||
}))
|
||||
);
|
||||
// 整 App 范围(region 0)的运营在明细区已有"全部区域"行(同一存储键),此处排除避免同键两处编辑。
|
||||
const [groupDrafts, setGroupDrafts] = useState(() =>
|
||||
(kpi.operator_app_rows || [])
|
||||
.filter((row) => !row.whole_app_scope)
|
||||
.map((row) => ({
|
||||
appCode: row.app_code,
|
||||
appName: row.app_name || row.app_code,
|
||||
dailyText: usdInputText(row.daily_target_usd_minor),
|
||||
monthText: usdInputText(row.month_target_usd_minor),
|
||||
operatorAccount: row.operator_account || "",
|
||||
operatorLabel: row.operator_name || row.operator_account || `#${row.operator_user_id}`,
|
||||
regionNames: row.region_names || "",
|
||||
userId: row.operator_user_id
|
||||
}))
|
||||
);
|
||||
const [regionDrafts, setRegionDrafts] = useState(() =>
|
||||
(kpi.items || []).map((item) => ({
|
||||
appCode: item.app_code,
|
||||
appName: item.app_name || item.app_code,
|
||||
dailyText: usdInputText(item.daily_target_usd_minor),
|
||||
monthText: usdInputText(item.month_target_usd_minor),
|
||||
operatorAccount: item.operator_account || "",
|
||||
operatorLabel: operatorLabelOf(item),
|
||||
regionId: Number(item.region_id ?? 0),
|
||||
regionName: regionLabelOf(item),
|
||||
userId: item.operator_user_id
|
||||
}))
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
|
||||
const hasInvalid = [...appDrafts, ...groupDrafts, ...regionDrafts].some(draftInvalid);
|
||||
const hasRows = appDrafts.length > 0 || groupDrafts.length > 0 || regionDrafts.length > 0;
|
||||
|
||||
const updateAt = (setDrafts) => (index, key, value) => {
|
||||
setDrafts((current) => current.map((draft, draftIndex) => (draftIndex === index ? { ...draft, [key]: value } : draft)));
|
||||
};
|
||||
const updateAppDraft = updateAt(setAppDrafts);
|
||||
const updateGroupDraft = updateAt(setGroupDrafts);
|
||||
const updateRegionDraft = updateAt(setRegionDrafts);
|
||||
|
||||
const handleSave = async () => {
|
||||
const items = [
|
||||
...groupDrafts.map((draft) => ({
|
||||
appCode: draft.appCode,
|
||||
dailyTargetUsdMinor: usdInputToMinor(draft.dailyText),
|
||||
periodMonth: kpi.period_month,
|
||||
regionId: 0,
|
||||
targetUsdMinor: usdInputToMinor(draft.monthText),
|
||||
userId: draft.userId
|
||||
})),
|
||||
...regionDrafts.map((draft) => ({
|
||||
appCode: draft.appCode,
|
||||
dailyTargetUsdMinor: usdInputToMinor(draft.dailyText),
|
||||
periodMonth: kpi.period_month,
|
||||
regionId: draft.regionId,
|
||||
targetUsdMinor: usdInputToMinor(draft.monthText),
|
||||
userId: draft.userId
|
||||
}))
|
||||
];
|
||||
const appItems = appDrafts.map((draft) => ({
|
||||
appCode: draft.appCode,
|
||||
dailyTargetUsdMinor: usdInputToMinor(draft.dailyText),
|
||||
periodMonth: kpi.period_month,
|
||||
targetUsdMinor: usdInputToMinor(draft.monthText)
|
||||
}));
|
||||
setIsSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
await replaceSocialBiKpiTargets({ appItems, items });
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error?.message || "保存失败,请稍后重试");
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sbi-kpi-drawer-mask" onClick={onClose} role="presentation">
|
||||
<aside aria-label="配置充值目标" className="sbi-kpi-drawer" onClick={(event) => event.stopPropagation()} role="dialog">
|
||||
<header className="sbi-kpi-drawer-header">
|
||||
<div className="sbi-kpi-drawer-title">
|
||||
<strong>配置充值目标</strong>
|
||||
<small>{kpi.period_month} · App 总目标 / 运营×App / 运营×App×区域 三级 · 月/日目标填 0 表示清除</small>
|
||||
</div>
|
||||
<button aria-label="关闭" className="sbi-kpi-drawer-close" onClick={onClose} type="button">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="sbi-kpi-drawer-body">
|
||||
{hasRows ? (
|
||||
<>
|
||||
{appDrafts.length ? (
|
||||
<section className="sbi-kpi-drawer-section">
|
||||
<h4>App 总目标 <small>不分运营的全员共同目标</small></h4>
|
||||
<div className="sbi-table-scroll">
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">App</th>
|
||||
<th>当月目标 ($)</th>
|
||||
<th>当日目标 ($)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{appDrafts.map((draft, index) => (
|
||||
<tr key={draft.appCode}>
|
||||
<td className="is-left">
|
||||
<span className="sbi-kpi-app">
|
||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||||
{draft.appName}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.monthText }}
|
||||
label={`${draft.appName} App 当月总目标(美元)`}
|
||||
onChange={(event) => updateAppDraft(index, "monthText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.dailyText }}
|
||||
label={`${draft.appName} App 当日总目标(美元)`}
|
||||
onChange={(event) => updateAppDraft(index, "dailyText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{groupDrafts.length ? (
|
||||
<section className="sbi-kpi-drawer-section">
|
||||
<h4>运营 × App 总目标 <small>该运营在此 App 全部负责区域的总盘子</small></h4>
|
||||
<div className="sbi-table-scroll">
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">运营人员</th>
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">负责区域</th>
|
||||
<th>当月目标 ($)</th>
|
||||
<th>当日目标 ($)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupDrafts.map((draft, index) => (
|
||||
<tr key={`${draft.userId}-${draft.appCode}`}>
|
||||
<td className="is-left">
|
||||
<div className="sbi-kpi-person">
|
||||
<span className="sbi-kpi-person-top">
|
||||
<strong>{draft.operatorLabel}</strong>
|
||||
</span>
|
||||
{draft.operatorAccount ? <small>{draft.operatorAccount}</small> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<span className="sbi-kpi-app">
|
||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||||
{draft.appName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="is-left">{draft.regionNames}</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.monthText }}
|
||||
label={`${draft.operatorLabel} ${draft.appName} 当月总目标(美元)`}
|
||||
onChange={(event) => updateGroupDraft(index, "monthText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.dailyText }}
|
||||
label={`${draft.operatorLabel} ${draft.appName} 当日总目标(美元)`}
|
||||
onChange={(event) => updateGroupDraft(index, "dailyText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{regionDrafts.length ? (
|
||||
<section className="sbi-kpi-drawer-section">
|
||||
<h4>运营 × App × 区域 明细目标</h4>
|
||||
<div className="sbi-table-scroll">
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">运营人员</th>
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">区域</th>
|
||||
<th>当月目标 ($)</th>
|
||||
<th>当日目标 ($)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{regionDrafts.map((draft, index) => (
|
||||
<tr key={`${draft.userId}-${draft.appCode}-${draft.regionId}-${index}`}>
|
||||
<td className="is-left">
|
||||
<div className="sbi-kpi-person">
|
||||
<span className="sbi-kpi-person-top">
|
||||
<strong>{draft.operatorLabel}</strong>
|
||||
</span>
|
||||
{draft.operatorAccount ? <small>{draft.operatorAccount}</small> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<span className="sbi-kpi-app">
|
||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||||
{draft.appName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="is-left">{draft.regionName}</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.monthText }}
|
||||
label={`${draft.operatorLabel} ${draft.appName} ${draft.regionName} 当月目标(美元)`}
|
||||
onChange={(event) => updateRegionDraft(index, "monthText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TargetInputCell
|
||||
draft={{ ...draft, value: draft.dailyText }}
|
||||
label={`${draft.operatorLabel} ${draft.appName} ${draft.regionName} 当日目标(美元)`}
|
||||
onChange={(event) => updateRegionDraft(index, "dailyText", event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无可配置的目标行</strong>
|
||||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="sbi-kpi-drawer-footer">
|
||||
{saveError || hasInvalid ? (
|
||||
<span className="sbi-kpi-drawer-error" role="alert">
|
||||
{saveError || "目标金额需为非负数字"}
|
||||
</span>
|
||||
) : null}
|
||||
<Button disabled={isSaving} onClick={onClose} variant="text">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={hasInvalid || isSaving || !hasRows} onClick={handleSave} variant="contained">
|
||||
{isSaving ? "保存中…" : "保存目标"}
|
||||
</Button>
|
||||
</footer>
|
||||
</aside>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
/* 人员绩效视图(TeamKpiView)专属样式:仅消费 social-v2.css 的 token,类名前缀 sbi-kpi-。
|
||||
金银铜名次徽标与图表网格色 #eef3f9 为本视图的功能色,其余颜色一律走 token。 */
|
||||
/* 运营中心充值排行视图专属样式:仅消费 social-v2.css 的 token,类名前缀 sbi-kpi-。 */
|
||||
|
||||
.sbi-kpi-view {
|
||||
display: flex;
|
||||
@ -7,121 +6,6 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Hero:1 大卡(环形进度)+ 3 小卡 ---------- */
|
||||
|
||||
.sbi-kpi-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.sbi-kpi-ring {
|
||||
--sbi-kpi-pct: 0;
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 138px;
|
||||
height: 138px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(var(--sbi-accent) calc(var(--sbi-kpi-pct) * 1%), #eef3f9 0);
|
||||
}
|
||||
|
||||
.sbi-kpi-ring-hole {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--sbi-card);
|
||||
}
|
||||
|
||||
.sbi-kpi-ring-hole strong {
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-ring-hole strong.is-muted {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-kpi-ring-hole small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-stats {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-stats > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px dashed var(--sbi-border);
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-stats > div:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-stats dt {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-kpi-hero-stats dd {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 720;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.sbi-kpi-mini-label {
|
||||
align-self: flex-start;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 640;
|
||||
}
|
||||
|
||||
.sbi-kpi-mini-value {
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-mini-note {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ---------- 排行榜 ---------- */
|
||||
|
||||
.sbi-kpi-board-scroll {
|
||||
max-height: 620px;
|
||||
margin-top: 12px;
|
||||
@ -185,248 +69,8 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-kpi-attain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sbi-kpi-bar {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eef3f9;
|
||||
}
|
||||
|
||||
.sbi-kpi-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--sbi-accent-grad);
|
||||
}
|
||||
|
||||
.sbi-kpi-bar-fill.is-done {
|
||||
background: var(--sbi-up);
|
||||
}
|
||||
|
||||
.sbi-kpi-attain-value {
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-no-target {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 骨架 ---------- */
|
||||
|
||||
.sbi-kpi-hero-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.sbi-kpi-skeleton-ring {
|
||||
flex: none;
|
||||
width: 138px;
|
||||
height: 138px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-kpi-skeleton-lines {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sbi-kpi-table-skeleton {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
/* ---------- 配置目标抽屉 ---------- */
|
||||
|
||||
.sbi-kpi-drawer-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(16, 27, 45, 0.42);
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(760px, 94vw);
|
||||
border-left: 1px solid var(--sbi-border);
|
||||
background: var(--sbi-card);
|
||||
box-shadow: var(--sbi-shadow);
|
||||
animation: sbi-kpi-slide-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes sbi-kpi-slide-in {
|
||||
from {
|
||||
transform: translateX(28px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--sbi-border);
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-title {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-title strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-title small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-close {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 8px;
|
||||
background: var(--sbi-card);
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-close:hover {
|
||||
border-color: var(--sbi-accent);
|
||||
color: var(--sbi-accent);
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--sbi-border);
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-error {
|
||||
margin-right: auto;
|
||||
color: var(--sbi-down);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-kpi-input {
|
||||
width: 112px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 7px;
|
||||
background: var(--sbi-card);
|
||||
color: var(--sbi-text);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-input:focus {
|
||||
border-color: var(--sbi-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.12);
|
||||
}
|
||||
|
||||
.sbi-kpi-input.is-invalid {
|
||||
border-color: var(--sbi-down);
|
||||
}
|
||||
|
||||
/* App 总目标进度条(全员共同目标) */
|
||||
|
||||
.sbi-kpi-app-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card-head strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card-amounts strong {
|
||||
font-size: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card-amounts small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-card-note {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* 目标抽屉分区 */
|
||||
|
||||
.sbi-kpi-drawer-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-section > h4 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--sbi-text);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.sbi-kpi-drawer-section > h4 > small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@ -42,8 +42,6 @@ export const PERMISSIONS = {
|
||||
coinSellerStockCredit: "coin-seller:stock-credit",
|
||||
coinSellerExchangeRate: "coin-seller:exchange-rate",
|
||||
hostWithdrawalView: "host-withdrawal:view",
|
||||
databiKpiManage: "databi-kpi:manage",
|
||||
databiKpiViewAll: "databi-kpi:view-all",
|
||||
financeView: "finance:view",
|
||||
financeApplicationCreate: "finance-application:create",
|
||||
financeApplicationAudit: "finance-application:audit",
|
||||
|
||||
@ -223,7 +223,6 @@ export const API_OPERATIONS = {
|
||||
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
|
||||
listSelfGames: "listSelfGames",
|
||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||
listSocialBiKpiTargets: "listSocialBiKpiTargets",
|
||||
listSplashScreens: "listSplashScreens",
|
||||
listSystemMenus: "listSystemMenus",
|
||||
listTaskDefinitions: "listTaskDefinitions",
|
||||
@ -256,7 +255,6 @@ export const API_OPERATIONS = {
|
||||
replaceRegionCountries: "replaceRegionCountries",
|
||||
replaceRoleDataScopes: "replaceRoleDataScopes",
|
||||
replaceRolePermissions: "replaceRolePermissions",
|
||||
replaceSocialBiKpiTargets: "replaceSocialBiKpiTargets",
|
||||
replaceUserFinanceScopes: "replaceUserFinanceScopes",
|
||||
resetUserPassword: "resetUserPassword",
|
||||
retryRedPacketRefund: "retryRedPacketRefund",
|
||||
@ -1813,13 +1811,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "seven-day-checkin:view",
|
||||
permissions: ["seven-day-checkin:view"]
|
||||
},
|
||||
listSocialBiKpiTargets: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSocialBiKpiTargets,
|
||||
path: "/v1/admin/databi/social/kpi-targets",
|
||||
permission: "overview:view",
|
||||
permissions: ["overview:view"]
|
||||
},
|
||||
listSplashScreens: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSplashScreens,
|
||||
@ -2034,13 +2025,6 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "role:permission",
|
||||
permissions: ["role:permission","role:manage"]
|
||||
},
|
||||
replaceSocialBiKpiTargets: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceSocialBiKpiTargets,
|
||||
path: "/v1/admin/databi/social/kpi-targets",
|
||||
permission: "databi-kpi:manage",
|
||||
permissions: ["databi-kpi:manage"]
|
||||
},
|
||||
replaceUserFinanceScopes: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.replaceUserFinanceScopes,
|
||||
|
||||
68
src/shared/api/generated/schema.d.ts
vendored
68
src/shared/api/generated/schema.d.ts
vendored
@ -2100,22 +2100,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/databi/social/kpi-targets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listSocialBiKpiTargets"];
|
||||
put: operations["replaceSocialBiKpiTargets"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/finance/scope": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -7284,58 +7268,6 @@ export interface operations {
|
||||
200: components["responses"]["StatisticsObjectResponse"];
|
||||
};
|
||||
};
|
||||
listSocialBiKpiTargets: {
|
||||
parameters: {
|
||||
query?: {
|
||||
period_month?: string;
|
||||
user_id?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["StatisticsObjectResponse"];
|
||||
};
|
||||
};
|
||||
replaceSocialBiKpiTargets: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
items?: {
|
||||
/** Format: int64 */
|
||||
userId?: number;
|
||||
appCode?: string;
|
||||
/** Format: int64 */
|
||||
regionId?: number;
|
||||
periodMonth?: string;
|
||||
/** Format: int64 */
|
||||
targetUsdMinor?: number;
|
||||
/** Format: int64 */
|
||||
dailyTargetUsdMinor?: number;
|
||||
}[];
|
||||
appItems?: {
|
||||
appCode?: string;
|
||||
periodMonth?: string;
|
||||
/** Format: int64 */
|
||||
targetUsdMinor?: number;
|
||||
/** Format: int64 */
|
||||
dailyTargetUsdMinor?: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["StatisticsObjectResponse"];
|
||||
};
|
||||
};
|
||||
getFinanceScope: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user