数据改动

This commit is contained in:
zhx 2026-07-09 16:16:31 +08:00
parent 50da0a569b
commit c7ca4f23ec
13 changed files with 1702 additions and 54 deletions

View File

@ -2836,9 +2836,165 @@
"operationId": "listCoinSellerLedger",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CoinSellerLedgerPageResponse"
}
},
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"name": "seller_user_id",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "seller_keyword",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_filter_user_id",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_display_user_id",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_username",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "ledger_type",
"in": "query",
"schema": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
}
},
{
"name": "start_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "end_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"x-permission": "coin-seller-ledger:view",
"x-permissions": ["coin-seller-ledger:view"]
}
},
"/admin/operations/coin-seller-ledger/export": {
"get": {
"operationId": "exportCoinSellerLedger",
"responses": {
"200": {
"description": "CSV",
"content": {
"text/csv": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
},
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"name": "seller_user_id",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "seller_keyword",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_filter_user_id",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_display_user_id",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "seller_username",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "ledger_type",
"in": "query",
"schema": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
}
},
{
"name": "start_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "end_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"x-permission": "coin-seller-ledger:view",
"x-permissions": ["coin-seller-ledger:view"]
}
@ -3552,6 +3708,50 @@
"x-permissions": ["overview:view"]
}
},
"/admin/databi/social/requirements": {
"get": {
"operationId": "getSocialBiRequirements",
"x-permission": "overview:view",
"parameters": [
{ "in": "query", "name": "stat_tz", "schema": { "type": "string" } },
{ "in": "query", "name": "start_ms", "schema": { "type": "integer", "format": "int64" } },
{ "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } },
{ "in": "query", "name": "app_codes", "schema": { "type": "string" } },
{ "in": "query", "name": "region_id", "schema": { "type": "integer", "format": "int64" } },
{ "in": "query", "name": "region_ids", "description": "逗号分隔的大区 ID用于 Social BI 顶栏多选区域。", "schema": { "type": "string" } },
{
"in": "query",
"name": "section",
"schema": {
"type": "string",
"enum": ["new_users", "revenue", "retention", "active", "gift", "game"]
}
},
{
"in": "query",
"name": "user_role",
"schema": {
"type": "string",
"enum": ["all", "host", "user"]
}
},
{
"in": "query",
"name": "payer_type",
"schema": {
"type": "string",
"enum": ["all", "paid", "unpaid"]
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/SocialBiRequirementsResponse"
}
},
"x-permissions": ["overview:view"]
}
},
"/admin/databi/social/funnel": {
"get": {
"operationId": "getSocialBiFunnel",
@ -6861,6 +7061,16 @@
}
}
},
"CoinSellerLedgerPageResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCoinSellerLedgerPage"
}
}
}
},
"BDProfilePageResponse": {
"description": "OK",
"content": {
@ -7171,6 +7381,16 @@
}
}
},
"SocialBiRequirementsResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseSocialBiRequirements"
}
}
}
},
"TeamListResponse": {
"description": "OK",
"content": {
@ -7297,6 +7517,27 @@
}
}
},
"ApiPageCoinSellerLedger": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CoinSellerLedger"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"ApiPageBDProfile": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
@ -7653,6 +7894,21 @@
}
]
},
"ApiResponseCoinSellerLedgerPage": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/ApiPageCoinSellerLedger"
}
}
}
]
},
"ApiResponseBDProfilePage": {
"allOf": [
{
@ -7777,6 +8033,159 @@
}
]
},
"ApiResponseSocialBiRequirements": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/SocialBiRequirementsResult"
}
}
}
]
},
"SocialBiRequirementsResult": {
"type": "object",
"properties": {
"apps": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsApp"
}
},
"access": {
"$ref": "#/components/schemas/SocialBiRequirementsAccess"
}
},
"required": ["apps"]
},
"SocialBiRequirementsApp": {
"type": "object",
"properties": {
"app_code": { "type": "string" },
"app_name": { "type": "string" },
"kind": { "type": "string" },
"restricted": { "type": "boolean" },
"allowed_region_ids": {
"type": "array",
"items": { "type": "integer", "format": "int64" }
},
"sections": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsSection"
}
},
"updated_at_ms": { "type": "integer", "format": "int64" },
"error": { "type": "string" }
},
"required": ["app_code", "app_name", "kind", "sections"]
},
"SocialBiRequirementsSection": {
"type": "object",
"properties": {
"key": {
"type": "string",
"enum": ["new_users", "revenue", "retention", "active", "gift", "game"]
},
"label": { "type": "string" },
"filters": {
"$ref": "#/components/schemas/SocialBiRequirementsFilters"
},
"columns": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsColumn"
}
},
"metric_definitions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsMetricDefinition"
}
},
"total": {
"$ref": "#/components/schemas/SocialBiRequirementsRow"
},
"daily_series": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsRow"
}
}
},
"required": ["key", "columns", "daily_series"]
},
"SocialBiRequirementsFilters": {
"type": "object",
"properties": {
"user_role": {
"type": "string",
"enum": ["all", "host", "user"]
},
"payer_type": {
"type": "string",
"enum": ["all", "paid", "unpaid"]
}
}
},
"SocialBiRequirementsColumn": {
"type": "object",
"properties": {
"key": { "type": "string" },
"label": { "type": "string" },
"type": {
"type": "string",
"description": "count、ratio、money_minor、coin、duration_ms 或 number。"
},
"tooltip": { "type": "string" }
},
"required": ["key", "label", "type"]
},
"SocialBiRequirementsMetricDefinition": {
"type": "object",
"properties": {
"metric": { "type": "string" },
"definition": { "type": "string" }
},
"required": ["metric", "definition"]
},
"SocialBiRequirementsRow": {
"type": "object",
"description": "日明细或汇总行;指标以 columns.key 对应的动态字段返回,兼容历史 metrics 对象。",
"properties": {
"stat_day": { "type": "string" },
"label": { "type": "string" },
"metrics": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
},
"SocialBiRequirementsAccess": {
"type": "object",
"properties": {
"all": { "type": "boolean" },
"scopes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocialBiRequirementsScope"
}
}
}
},
"SocialBiRequirementsScope": {
"type": "object",
"properties": {
"app_code": { "type": "string" },
"region_id": { "type": "integer", "format": "int64" }
}
},
"ApiResponseFinanceApplication": {
"allOf": [
{
@ -8385,6 +8794,124 @@
}
}
},
"CoinLedgerUser": {
"type": "object",
"required": ["userId"],
"properties": {
"avatar": {
"type": "string"
},
"displayUserId": {
"type": "string"
},
"userId": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"CoinSellerLedger": {
"type": "object",
"required": ["entryId", "transactionId", "ledgerType", "bizType", "seller", "receiver", "amount", "direction", "availableDelta", "sellerBalanceAfter", "createdAtMs"],
"properties": {
"amount": {
"type": "integer",
"format": "int64"
},
"availableDelta": {
"type": "integer",
"format": "int64"
},
"bizType": {
"type": "string",
"enum": [
"coin_seller_stock_purchase",
"coin_seller_stock_deduction",
"coin_seller_recharge",
"coin_seller_coin_compensation",
"coin_seller_transfer",
"coin_seller_sub_transfer",
"salary_transfer_to_coin_seller",
"manual_credit"
]
},
"commandId": {
"type": "string"
},
"counterpartyUserId": {
"type": "string"
},
"createdAtMs": {
"type": "integer",
"format": "int64"
},
"direction": {
"type": "string",
"enum": ["income", "expense"]
},
"entryId": {
"type": "integer",
"format": "int64"
},
"ledgerType": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
},
"metadata": {
"type": "object",
"additionalProperties": true
},
"operator": {
"type": "object",
"properties": {
"adminId": {
"type": "string"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"operatorUserId": {
"type": "string"
},
"paidAmountMicro": {
"type": "integer",
"format": "int64"
},
"paidCurrencyCode": {
"type": "string"
},
"reason": {
"type": "string"
},
"receiver": {
"$ref": "#/components/schemas/CoinLedgerUser"
},
"seller": {
"$ref": "#/components/schemas/CoinLedgerUser"
},
"sellerBalanceAfter": {
"type": "integer",
"format": "int64"
},
"stockType": {
"type": "string"
},
"transactionId": {
"type": "string"
},
"transferUsdMinor": {
"type": "integer",
"format": "int64"
}
}
},
"CreateCoinSellerPayload": {
"type": "object",
"required": ["commandId", "targetUserId"],

View File

@ -8,6 +8,7 @@ import {
fetchSocialBiKpi,
fetchSocialBiMaster,
fetchSocialBiOverview,
fetchSocialBiRequirements,
fetchStatisticsOverview
} from "./api.js";
@ -18,6 +19,7 @@ vi.mock("./api.js", () => ({
fetchSocialBiKpi: vi.fn(),
fetchSocialBiMaster: vi.fn(),
fetchSocialBiOverview: vi.fn(),
fetchSocialBiRequirements: vi.fn(),
fetchSelfGameStatisticsOverview: vi.fn(),
fetchStatisticsOverview: vi.fn(),
getCurrentAppCode: vi.fn(() => "lalu"),
@ -58,12 +60,14 @@ beforeEach(() => {
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
fetchSocialBiRequirements.mockResolvedValue({ columns: [], daily_rows: [], section: "new_users", total: null });
});
afterEach(() => {
window.history.pushState(null, "", "/");
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
test("keeps current data visible during scheduled refresh", async () => {
@ -360,6 +364,119 @@ test("routes databi social page to Social BI and loads real overview rows", asyn
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
});
test("renders social BI data requirements table with section filters and CSV export", async () => {
window.history.pushState(null, "", "/databi/social/?view=table&regions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", daily_series: [], kind: "hyapp", total: {} }]
});
fetchSocialBiRequirements.mockImplementation(async ({ section }) => {
if (section === "revenue") {
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "recharge_users", label: "充值用户", type: "count" },
{ key: "recharge_usd_minor", label: "充值金额", type: "money_minor" }
],
daily_series: [{ metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "total" }
}]
}]
};
}
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "app_download_users", label: "下载APP人数", type: "count" },
{ key: "registration_success_rate", label: "注册成功率", type: "ratio" }
],
daily_series: [{ metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "total" }
}]
}]
};
});
render(<DatabiApp />);
await flushEffects();
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
await act(async () => {
screen.getByRole("radio", { name: "数据需求" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
payerType: "all",
regionIds: [9, 11],
section: "new_users",
userRole: "all"
}));
expect(screen.getByRole("tab", { name: "P0新用户" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("下载APP人数")).toBeTruthy();
expect(screen.getByText("注册成功率")).toBeTruthy();
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
expect(screen.getAllByText("66.66%").length).toBeGreaterThan(0);
expect(screen.queryByRole("radiogroup", { name: "用户身份" })).toBeNull();
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
await act(async () => {
screen.getByRole("tab", { name: "P1营收" }).click();
});
await flushEffects();
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
await act(async () => {
screen.getByRole("radio", { name: "主播" }).click();
});
await flushEffects();
await act(async () => {
screen.getByRole("radio", { name: "已付费" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({
payerType: "paid",
section: "revenue",
userRole: "host"
}));
expect(screen.getByText("充值用户")).toBeTruthy();
expect(screen.getByText("充值金额")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
const urlMock = { createObjectURL: vi.fn(() => "blob:social-bi-requirements"), revokeObjectURL: vi.fn() };
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.stubGlobal("URL", urlMock);
await act(async () => {
screen.getByRole("button", { name: "导出 CSV" }).click();
});
expect(urlMock.createObjectURL).toHaveBeenCalledTimes(1);
expect(anchorClick).toHaveBeenCalledTimes(1);
anchorClick.mockRestore();
});
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
window.history.pushState(null, "", "/databi/social/?view=funnel");
fetchSocialBiMaster.mockResolvedValue({

View File

@ -180,6 +180,37 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
}
export async function fetchSocialBiRequirements({ appCodes, endMs, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
const query = {};
if (statTz) {
query.stat_tz = statTz;
}
if (startMs) {
query.start_ms = String(startMs);
}
if (endMs) {
query.end_ms = String(endMs);
}
if (appCodes?.length) {
query.app_codes = appCodes.join(",");
}
if (regionId) {
query.region_id = String(regionId);
} else if (regionIds?.length) {
query.region_ids = regionIds.join(",");
}
if (section) {
query.section = section;
}
if (userRole) {
query.user_role = userRole;
}
if (payerType) {
query.payer_type = payerType;
}
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
}
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
const query = {};
if (statTz) {

View File

@ -1,5 +1,13 @@
import { afterEach, expect, test, vi } from "vitest";
import { fetchFilterOptions, fetchSocialBiFilterOptions, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
import {
fetchFilterOptions,
fetchSocialBiFilterOptions,
fetchSocialBiRequirements,
fetchStatisticsOverview,
getCurrentAppCode,
getDefaultAppOptions,
setCurrentAppCode
} from "./api.js";
afterEach(() => {
window.localStorage.clear();
@ -87,6 +95,31 @@ test("loads social BI apps and operation users from admin APIs", async () => {
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
});
test("loads social BI requirements with section role and payer filters", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
const data = await fetchSocialBiRequirements({
appCodes: ["lalu", "huwaa"],
endMs: 20,
payerType: "paid",
regionIds: [9, 11],
section: "revenue",
startMs: 10,
statTz: "Asia/Shanghai",
userRole: "host"
});
const requestURL = new URL(String(fetch.mock.calls[0][0]));
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/requirements");
expect(requestURL.searchParams.get("app_codes")).toBe("lalu,huwaa");
expect(requestURL.searchParams.get("section")).toBe("revenue");
expect(requestURL.searchParams.get("user_role")).toBe("host");
expect(requestURL.searchParams.get("payer_type")).toBe("paid");
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
expect(data.total.recharge_users).toBe(2);
});
test("omits app scope headers and query params for all databi apps", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));

View File

@ -2,7 +2,7 @@
// 视图不直接调 API只消费这里的产物原始 snake_case 指标行 + 维度字段)。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js";
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
@ -16,10 +16,14 @@ export function useSocialBiData(filters) {
const [overview, setOverview] = useState(null);
const [funnel, setFunnel] = useState(null);
const [kpi, setKpi] = useState(null);
const [requirements, setRequirements] = useState(null);
const [requirementsError, setRequirementsError] = useState("");
const [isRequirementsLoading, setIsRequirementsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState([]);
const [refreshToken, setRefreshToken] = useState(0);
const requestSeq = useRef(0);
const requirementsSeq = useRef(0);
useEffect(() => {
let ignore = false;
@ -119,6 +123,58 @@ export function useSocialBiData(filters) {
});
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
const loadRequirements = useCallback(
({ payerType, section, userRole } = {}) => {
if (!isMasterSettled) {
return Promise.resolve(null);
}
const sequence = ++requirementsSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
setIsRequirementsLoading(true);
setRequirementsError("");
setRequirements(null);
if (!requestScopes.length) {
const emptyResult = { access: overview?.access || null, apps: [] };
setRequirements(emptyResult);
setIsRequirementsLoading(false);
return Promise.resolve(emptyResult);
}
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
...scope,
endMs,
payerType,
section,
startMs,
statTz: SOCIAL_BI_TZ,
userRole
})))
.then((results) => {
if (sequence !== requirementsSeq.current) {
return null;
}
const data = mergeRequirementResponses(results);
setRequirements(data);
return data;
})
.catch((error) => {
if (sequence === requirementsSeq.current) {
setRequirements(null);
setRequirementsError(error?.message || "数据需求加载失败");
}
return null;
})
.finally(() => {
if (sequence === requirementsSeq.current) {
setIsRequirementsLoading(false);
}
});
},
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
);
const refresh = useCallback(() => {
setRefreshToken((current) => current + 1);
}, []);
@ -132,10 +188,15 @@ export function useSocialBiData(filters) {
funnel,
funnelAppCodes,
isLoading,
isRequirementsLoading,
kpi,
loadRequirements,
master,
overview,
range,
refreshToken,
requirements,
requirementsError,
refresh
};
}
@ -236,3 +297,61 @@ function buildRegionByCountryIndex(master) {
});
return index;
}
function buildRequirementRequestScopes(appCodes, regions) {
const selectedApps = new Set((appCodes || []).filter(Boolean));
if (!regions?.length || regions.includes(ALL)) {
return [{ appCodes: [...selectedApps] }];
}
const byApp = new Map();
regions.forEach((selection) => {
const value = String(selection || "").trim();
if (!value || value === ALL) {
return;
}
if (value.startsWith("app:")) {
const appCode = value.slice(4).trim().toLowerCase();
if (appCode) {
byApp.set(appCode, null);
}
return;
}
const [rawAppCode, rawRegionID] = value.split(":");
const appCode = String(rawAppCode || "").trim().toLowerCase();
const regionID = Number(rawRegionID);
if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) {
return;
}
if (!byApp.has(appCode)) {
byApp.set(appCode, new Set());
}
byApp.get(appCode).add(regionID);
});
const scopes = [];
byApp.forEach((regionIDs, appCode) => {
if (selectedApps.size && !selectedApps.has(appCode)) {
return;
}
if (regionIDs === null) {
scopes.push({ appCodes: [appCode] });
return;
}
const ids = [...regionIDs].sort((left, right) => left - right);
if (ids.length === 1) {
scopes.push({ appCodes: [appCode], regionId: ids[0] });
} else if (ids.length > 1) {
scopes.push({ appCodes: [appCode], regionIds: ids });
}
});
return scopes;
}
function mergeRequirementResponses(results) {
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
results.forEach((item) => {
if (Array.isArray(item?.apps)) {
merged.apps.push(...item.apps);
}
});
return merged;
}

View File

@ -1,7 +1,7 @@
// DataTableView Excel / / /
// / CSV snake_case metrics.js
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import { useSocialBi } from "../SocialBiApp.jsx";
@ -13,7 +13,7 @@ import {
metricLabel,
metricTooltip
} from "../metrics.js";
import { downloadCsv, isBlank } from "../format.js";
import { downloadCsv, formatCount, formatDurationMs, formatMoneyMinor, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./data-table-view.css";
@ -39,6 +39,104 @@ const DIMENSIONS = [
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
];
const TABLE_MODES = [
{ key: "wide", label: "运营宽表" },
{ key: "requirements", label: "数据需求" }
];
const REQUIREMENT_SECTIONS = [
{
csvName: "p0-new-users",
fallbackColumns: [
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
{ key: "new_users", label: "新增注册", type: "count" },
{ key: "profile_complete_users", label: "完善资料", type: "count" },
{ key: "first_room_join_users", label: "首次进房", type: "count" }
],
key: "new_users",
label: "P0新用户"
},
{
csvName: "p1-revenue",
fallbackColumns: [
{ key: "recharge_usd_minor", label: "充值", type: "money_minor" },
{ key: "recharge_users", label: "充值用户", type: "count" },
{ key: "first_recharge_users", label: "首充用户", type: "count" },
{ key: "repeat_recharge_users", label: "复购用户", type: "count" },
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
],
key: "revenue",
label: "P1营收",
payerFilter: true,
roleFilter: true
},
{
csvName: "p2-retention",
fallbackColumns: [
{ key: "cohort_users", label: "留存基数", type: "count" },
{ key: "d1_retention_users", label: "次留用户", type: "count" },
{ key: "d1_retention_rate", label: "次留率", type: "ratio" },
{ key: "d7_retention_rate", label: "7日留存", type: "ratio" },
{ key: "d30_retention_rate", label: "30日留存", type: "ratio" }
],
key: "retention",
label: "P2留存",
payerFilter: true,
roleFilter: true
},
{
csvName: "p2-active",
fallbackColumns: [
{ key: "active_users", label: "活跃用户", type: "count" },
{ key: "room_join_users", label: "进房用户", type: "count" },
{ key: "mic_users", label: "上麦用户", type: "count" },
{ key: "message_users", label: "发言用户", type: "count" },
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
],
key: "active",
label: "P2活跃",
payerFilter: true,
roleFilter: true
},
{
csvName: "p4-gift",
fallbackColumns: [
{ key: "gift_panel_open_users", label: "礼物面板用户", type: "count" },
{ key: "gift_senders", label: "送礼用户", type: "count" },
{ key: "gift_coin_spent", label: "礼物流水", type: "coin" },
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
],
key: "gift",
label: "P4送礼",
roleFilter: true
},
{
csvName: "p5-game",
fallbackColumns: [
{ key: "probability_game_panel_open_users", label: "概率游戏面板用户", type: "count" },
{ key: "game_players", label: "押注用户", type: "count" },
{ key: "game_turnover", label: "游戏流水", type: "coin" },
{ key: "game_profit", label: "游戏利润", type: "coin" },
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
],
key: "game",
label: "P5游戏",
roleFilter: true
}
];
const USER_ROLE_OPTIONS = [
{ label: "全部角色", value: "all" },
{ label: "主播", value: "host" },
{ label: "用户", value: "user" }
];
const PAYER_TYPE_OPTIONS = [
{ label: "全部付费", value: "all" },
{ label: "已付费", value: "paid" },
{ label: "未付费", value: "unpaid" }
];
function readClosedGroups() {
try {
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
@ -89,16 +187,215 @@ function rowIdentity(row, dimKey, index) {
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
}
function requirementSectionConfig(key) {
return REQUIREMENT_SECTIONS.find((section) => section.key === key) || REQUIREMENT_SECTIONS[0];
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function sectionMatches(item, sectionKey) {
return String(item?.section ?? item?.key ?? item?.code ?? "") === sectionKey;
}
function normalizeRequirementColumn(column) {
const key = String(column?.key ?? column?.field ?? column?.name ?? "").trim();
if (!key) {
return null;
}
return {
key,
label: column.label || column.title || column.name || key,
tooltip: column.tooltip || column.description || column.remark || "",
type: column.type || column.value_type || column.format || ""
};
}
function requirementAppContext(app) {
return {
app_code: app?.app_code || app?.appCode || "",
app_name: app?.app_name || app?.appName || app?.app_code || app?.appCode || "",
kind: app?.kind || "",
restricted: Boolean(app?.restricted)
};
}
function flattenRequirementRow(row, context = {}) {
if (!row || typeof row !== "object") {
return null;
}
const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
const flattened = { ...context, ...row, ...metrics };
delete flattened.metrics;
return flattened;
}
function normalizedRequirementColumns(sectionItem, fallbackColumns) {
const columns = asArray(sectionItem?.columns || sectionItem?.fields || sectionItem?.metrics).map(normalizeRequirementColumn).filter(Boolean);
return columns.length ? columns : fallbackColumns;
}
function normalizeRequirementPayload(payload, sectionKey) {
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
const appSections = asArray(payload?.apps).flatMap((app) => {
const context = requirementAppContext(app);
return asArray(app?.sections)
.filter((section) => sectionMatches(section, sectionKey))
.map((section) => ({ context, section }));
});
if (appSections.length) {
const firstSection = appSections.find(({ section }) => asArray(section?.columns || section?.fields || section?.metrics).length)?.section
|| appSections[0].section;
const columns = normalizedRequirementColumns(firstSection, fallbackColumns);
if (appSections.length === 1) {
const { context, section } = appSections[0];
return {
columns,
rows: asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean),
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
};
}
const rows = appSections.flatMap(({ context, section }) => {
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
const dailyRows = asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean);
return total ? [total, ...dailyRows] : dailyRows;
});
return { columns, rows, total: null };
}
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
|| asArray(payload?.items).find((item) => sectionMatches(item, sectionKey) && (item.columns || item.daily_rows || item.dailyRows || item.total || item.totals))
|| payload
|| {};
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
const rows = asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
.map((row) => flattenRequirementRow(row))
.filter(Boolean);
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
return {
columns,
rows,
total
};
}
function inferRequirementType(column) {
const type = String(column.type || "").toLowerCase();
const key = String(column.key || "").toLowerCase();
if (type.includes("money") || key.endsWith("_usd_minor")) {
return "money_minor";
}
if (type.includes("ratio") || type.includes("rate") || type.includes("percent") || key.endsWith("_rate")) {
return "ratio";
}
if (type.includes("duration") || key.endsWith("_ms")) {
return "duration_ms";
}
if (type.includes("coin") || key.endsWith("_coin") || key.endsWith("_turnover") || key.endsWith("_profit")) {
return "coin";
}
return type || "number";
}
function truncateTwoDecimals(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "--";
}
const truncated = Math.trunc(numeric * 100) / 100;
return truncated.toLocaleString("en-US", { maximumFractionDigits: 2 });
}
function formatRequirementRatio(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "--";
}
return `${truncateTwoDecimals(numeric * 100)}%`;
}
function formatRequirementValue(column, value) {
if (isBlank(value)) {
return "--";
}
const type = inferRequirementType(column);
if (type === "money_minor") {
return formatMoneyMinor(value);
}
if (type === "ratio") {
return formatRequirementRatio(value);
}
if (type === "duration_ms") {
return formatDurationMs(value);
}
if (type === "coin" || type === "count" || type === "integer") {
return formatCount(value);
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric);
}
return String(value);
}
function csvRequirementValue(column, value) {
if (isBlank(value)) {
return "";
}
const type = inferRequirementType(column);
if (type === "money_minor") {
return Number(value) / 100;
}
if (type === "ratio") {
return Number(value) * 100;
}
return value;
}
function requirementRowIdentity(row, sectionKey, index) {
const parts = [row.app_code, row.stat_day, row.region_id].filter((part) => part !== undefined && part !== null && part !== "");
return parts.length ? `${sectionKey}:${parts.join("|")}` : `${sectionKey}:${index}`;
}
function requirementAppName(row) {
return row?.app_name || row?.app_code || "全部";
}
export function DataTableView() {
const { derived, isLoading, range } = useSocialBi();
const {
derived,
isLoading,
isRequirementsLoading,
loadRequirements,
range,
refreshToken,
requirements,
requirementsError
} = useSocialBi();
const [mode, setMode] = useState("wide");
const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null);
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all");
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
const hasDay = dim.hasDay;
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
const activeRequirementSection = requirementSectionConfig(requirementSection);
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
const requirementColumns = requirementData.columns;
const requirementRows = requirementData.rows;
const requirementTotal = requirementData.total;
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
@ -129,11 +426,33 @@ export function DataTableView() {
const hasRestricted = baseRows.some((row) => row.restricted);
const columnCount = dimColumns.length + visibleMetrics.length;
useEffect(() => {
if (mode !== "requirements") {
return;
}
loadRequirements({
payerType: effectiveRequirementPayer,
section: requirementSection,
userRole: effectiveRequirementRole
});
}, [effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
const handleDimChange = (key) => {
setDimKey(key);
setSort(null);
};
const handleRequirementSectionChange = (key) => {
const nextSection = requirementSectionConfig(key);
setRequirementSection(nextSection.key);
if (!nextSection.roleFilter) {
setRequirementRole("all");
}
if (!nextSection.payerFilter) {
setRequirementPayer("all");
}
};
const toggleGroup = (key) => {
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
setClosedGroups(next);
@ -157,9 +476,22 @@ export function DataTableView() {
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let content;
const handleRequirementsExport = () => {
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
const rows = [
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
...requirementRows
].map((row) => [
row.stat_day || "--",
requirementAppName(row),
...requirementColumns.map((column) => csvRequirementValue(column, row[column.key]))
]);
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let wideContent;
if (isLoading && !baseRows.length) {
content = (
wideContent = (
<div className="sbi-dt-skeleton" aria-label="数据加载中">
{SKELETON_WIDTHS.map((width) => (
<span className="sbi-skeleton" key={width} style={{ width }} />
@ -167,25 +499,19 @@ export function DataTableView() {
</div>
);
} else if (!baseRows.length) {
content = (
wideContent = (
<div className="sbi-empty">
<strong>当前维度暂无数据</strong>
<span>
{dim.dims.includes("region")
? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
: "试试更换日期区间、调整 App 筛选或切换其它维度"}
</span>
<strong>当前无数据</strong>
</div>
);
} else if (!visibleMetrics.length) {
content = (
wideContent = (
<div className="sbi-empty">
<strong>所有列组已隐藏</strong>
<span>点击上方列组开关恢复需要查看的指标列</span>
</div>
);
} else {
content = (
wideContent = (
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-table">
<thead>
@ -258,22 +584,91 @@ export function DataTableView() {
);
}
let requirementsContent;
if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
requirementsContent = (
<div className="sbi-dt-skeleton" aria-label="数据需求加载中">
{SKELETON_WIDTHS.map((width) => (
<span className="sbi-skeleton" key={width} style={{ width }} />
))}
</div>
);
} else if (requirementsError) {
requirementsContent = (
<div className="sbi-empty" role="alert">
<strong>{requirementsError}</strong>
</div>
);
} else if (!requirementRows.length && !requirementTotal) {
requirementsContent = (
<div className="sbi-empty">
<strong>当前无数据</strong>
</div>
);
} else {
requirementsContent = (
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-req-table">
<thead>
<tr>
<th className="is-left sbi-dt-sticky">日期</th>
<th className="is-left">App</th>
{requirementColumns.map((column) => (
<th key={column.key}>
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
{column.label}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{requirementTotal ? (
<tr className="is-total">
<td className="is-left sbi-dt-sticky">汇总</td>
<td className="is-left">全部</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, requirementTotal[column.key])}</td>
))}
</tr>
) : null}
{requirementRows.map((row, rowIndex) => (
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
const rowCount = mode === "requirements" ? requirementRows.length : sortedRows.length;
const content = mode === "requirements" ? requirementsContent : wideContent;
const exportDisabled = mode === "requirements"
? (!requirementRows.length && !requirementTotal) || isRequirementsLoading
: !sortedRows.length || !visibleMetrics.length;
return (
<section className="sbi-card">
<div className="sbi-card-header">
<strong>数据明细</strong>
<small>
{rangeLabel(range)} · {sortedRows.length}
{rangeLabel(range)} · {rowCount}
</small>
</div>
<div className="sbi-dt-tools">
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
{DIMENSIONS.map((item) => (
<div className="sbi-seg" role="radiogroup" aria-label="数据明细模式">
{TABLE_MODES.map((item) => (
<button
aria-checked={dim.key === item.key}
className={dim.key === item.key ? "is-active" : ""}
aria-checked={mode === item.key}
className={mode === item.key ? "is-active" : ""}
key={item.key}
onClick={() => handleDimChange(item.key)}
onClick={() => setMode(item.key)}
role="radio"
type="button"
>
@ -281,29 +676,82 @@ export function DataTableView() {
</button>
))}
</div>
<div className="sbi-dt-groups" role="group" aria-label="列组开关">
<span className="sbi-dt-groups-label">列组</span>
{METRIC_GROUPS.map((group) => {
const isOpen = !closedGroups.includes(group.key);
return (
<button
aria-pressed={isOpen}
className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
key={group.key}
onClick={() => toggleGroup(group.key)}
title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
type="button"
>
{group.label}
</button>
);
})}
</div>
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
{mode === "wide" ? (
<>
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
{DIMENSIONS.map((item) => (
<button
aria-checked={dim.key === item.key}
className={dim.key === item.key ? "is-active" : ""}
key={item.key}
onClick={() => handleDimChange(item.key)}
role="radio"
type="button"
>
{item.label}
</button>
))}
</div>
<div className="sbi-dt-groups" role="group" aria-label="列组开关">
<span className="sbi-dt-groups-label">列组</span>
{METRIC_GROUPS.map((group) => {
const isOpen = !closedGroups.includes(group.key);
return (
<button
aria-pressed={isOpen}
className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
key={group.key}
onClick={() => toggleGroup(group.key)}
title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
type="button"
>
{group.label}
</button>
);
})}
</div>
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
</>
) : (
<>
<div className="sbi-dt-section-tabs" role="tablist" aria-label="数据需求分组">
{REQUIREMENT_SECTIONS.map((section) => (
<button
aria-selected={activeRequirementSection.key === section.key}
className={activeRequirementSection.key === section.key ? "is-active" : ""}
key={section.key}
onClick={() => handleRequirementSectionChange(section.key)}
role="tab"
type="button"
>
{section.label}
</button>
))}
</div>
{activeRequirementSection.roleFilter ? (
<RequirementFilterGroup
ariaLabel="用户身份"
label="用户身份"
options={USER_ROLE_OPTIONS}
value={effectiveRequirementRole}
onChange={setRequirementRole}
/>
) : null}
{activeRequirementSection.payerFilter ? (
<RequirementFilterGroup
ariaLabel="付费身份"
label="付费身份"
options={PAYER_TYPE_OPTIONS}
value={effectiveRequirementPayer}
onChange={setRequirementPayer}
/>
) : null}
</>
)}
<div className="sbi-dt-export">
<Button
disabled={!sortedRows.length || !visibleMetrics.length}
onClick={handleExport}
disabled={exportDisabled}
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
@ -316,3 +764,25 @@ export function DataTableView() {
</section>
);
}
function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
return (
<div className="sbi-dt-filter-group">
<span className="sbi-dt-groups-label">{label}</span>
<div className="sbi-seg" role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => (
<button
aria-checked={value === option.value}
className={value === option.value ? "is-active" : ""}
key={option.value}
onClick={() => onChange(option.value)}
role="radio"
type="button"
>
{option.label}
</button>
))}
</div>
</div>
);
}

View File

@ -16,6 +16,37 @@
flex-wrap: wrap;
}
.sbi-dt-section-tabs {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.sbi-dt-section-tabs > button {
padding: 6px 12px;
border: 1px solid var(--sbi-border);
border-radius: 999px;
background: #ffffff;
color: var(--sbi-text-2);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.sbi-dt-section-tabs > button.is-active {
border-color: var(--sbi-accent);
color: var(--sbi-accent);
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
}
.sbi-dt-filter-group {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sbi-dt-groups-label {
color: var(--sbi-text-3);
font-size: 12px;
@ -57,24 +88,32 @@
color: var(--sbi-accent);
}
.sbi-dt-req-table th:first-child,
.sbi-dt-req-table td:first-child {
min-width: 104px;
}
.sbi-dt-sort {
margin-left: 3px;
font-size: 11px;
}
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
.sbi-dt-table .sbi-dt-sticky {
.sbi-dt-table .sbi-dt-sticky,
.sbi-dt-req-table .sbi-dt-sticky {
position: sticky;
left: 0;
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
}
.sbi-dt-table td.sbi-dt-sticky {
.sbi-dt-table td.sbi-dt-sticky,
.sbi-dt-req-table td.sbi-dt-sticky {
z-index: 1;
background: var(--sbi-card);
}
.sbi-dt-table th.sbi-dt-sticky {
.sbi-dt-table th.sbi-dt-sticky,
.sbi-dt-req-table th.sbi-dt-sticky {
z-index: 4;
}

View File

@ -119,7 +119,7 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
);
await listCoinSellerLedger({
ledger_type: "seller_transfer",
ledger_type: "sub_seller_transfer",
page: 1,
page_size: 20,
seller_user_id: "3001",
@ -129,7 +129,7 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger?");
expect(String(listUrl)).toContain("seller_user_id=3001");
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
expect(String(listUrl)).toContain("ledger_type=sub_seller_transfer");
expect(listInit?.method).toBe("GET");
});

View File

@ -141,8 +141,9 @@ export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<Coi
}
export function exportCoinSellerLedger(query: PageQuery = {}): Promise<Response> {
return apiRequest("/v1/admin/operations/coin-seller-ledger/export", {
method: "GET",
const endpoint = API_ENDPOINTS.exportCoinSellerLedger;
return apiRequest(apiEndpointPath(API_OPERATIONS.exportCoinSellerLedger), {
method: endpoint.method,
query,
raw: true,
});

View File

@ -20,6 +20,7 @@ const pageSize = 50;
const ledgerTypeOptions = [
{ label: "后台入账", value: "admin_stock_credit" },
{ label: "币商转用户", value: "seller_transfer" },
{ label: "向子币商转账", value: "sub_seller_transfer" },
{ label: "工资转币商", value: "salary_transfer_received" },
];
@ -27,12 +28,14 @@ const ledgerTypeLabels = {
admin_stock_credit: "后台入账",
salary_transfer_received: "工资转币商",
seller_transfer: "币商转用户",
sub_seller_transfer: "向子币商转账",
};
const bizTypeLabels = {
coin_seller_coin_compensation: "金币补偿",
coin_seller_stock_deduction: "USDT扣除",
coin_seller_stock_purchase: "USDT进货",
coin_seller_sub_transfer: "向子币商转账",
coin_seller_transfer: "币商转用户",
manual_credit: "金币增加",
salary_transfer_to_coin_seller: "工资转币商",

View File

@ -0,0 +1,59 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
usePaginatedQuery: vi.fn(),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("coin seller ledger shows sub seller transfer type and filter option", () => {
vi.mocked(usePaginatedQuery).mockReturnValue(queryFixture([subSellerTransferEntry()]));
render(<CoinSellerLedgerTable />);
expect(screen.getByText("向子币商转账")).toBeInTheDocument();
expect(screen.getByText("Parent seller")).toBeInTheDocument();
expect(screen.getByText("Child seller")).toBeInTheDocument();
expect(screen.getByText("-80")).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /流水类型/ })[0]);
expect(screen.getAllByText("向子币商转账").length).toBeGreaterThanOrEqual(2);
});
function queryFixture(items) {
return {
data: { items, page: 1, pageSize: 50, total: items.length },
error: null,
loading: false,
reload: vi.fn(),
};
}
function subSellerTransferEntry() {
return {
amount: 80,
availableDelta: -80,
bizType: "coin_seller_sub_transfer",
commandId: "cmd-sub-transfer",
counterpartyUserId: "5001",
createdAtMs: 1760000000000,
direction: "expense",
entryId: 991,
ledgerType: "sub_seller_transfer",
metadata: { child_user_id: 5001, parent_user_id: 3001 },
receiver: { displayUserId: "5001", userId: "5001", username: "Child seller" },
seller: { displayUserId: "3001", userId: "3001", username: "Parent seller" },
sellerBalanceAfter: 920,
transactionId: "tx-sub-transfer",
};
}

View File

@ -110,6 +110,7 @@ export const API_OPERATIONS = {
enableResourceGroup: "enableResourceGroup",
enableResourceShopItem: "enableResourceShopItem",
expireRoomRpsChallenge: "expireRoomRpsChallenge",
exportCoinSellerLedger: "exportCoinSellerLedger",
exportLoginLogs: "exportLoginLogs",
exportOperationLogs: "exportOperationLogs",
exportRechargeBills: "exportRechargeBills",
@ -150,6 +151,7 @@ export const API_OPERATIONS = {
getSocialBiKpi: "getSocialBiKpi",
getSocialBiMaster: "getSocialBiMaster",
getSocialBiOverview: "getSocialBiOverview",
getSocialBiRequirements: "getSocialBiRequirements",
getTemporaryPaymentLink: "getTemporaryPaymentLink",
getUser: "getUser",
getWeeklyStarCycle: "getWeeklyStarCycle",
@ -1037,6 +1039,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update",
permissions: ["game:update"]
},
exportCoinSellerLedger: {
method: "GET",
operationId: API_OPERATIONS.exportCoinSellerLedger,
path: "/v1/admin/operations/coin-seller-ledger/export",
permission: "coin-seller-ledger:view",
permissions: ["coin-seller-ledger:view"]
},
exportLoginLogs: {
method: "GET",
operationId: API_OPERATIONS.exportLoginLogs,
@ -1317,6 +1326,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "overview:view",
permissions: ["overview:view"]
},
getSocialBiRequirements: {
method: "GET",
operationId: API_OPERATIONS.getSocialBiRequirements,
path: "/v1/admin/databi/social/requirements",
permission: "overview:view",
permissions: ["overview:view"]
},
getTemporaryPaymentLink: {
method: "GET",
operationId: API_OPERATIONS.getTemporaryPaymentLink,

View File

@ -1876,6 +1876,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/operations/coin-seller-ledger/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["exportCoinSellerLedger"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/operations/full-server-notices/fanout": {
parameters: {
query?: never;
@ -2148,6 +2164,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/databi/social/requirements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getSocialBiRequirements"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/databi/social/funnel": {
parameters: {
query?: never;
@ -4064,6 +4096,12 @@ export interface components {
pageSize: number;
total: number;
};
ApiPageCoinSellerLedger: {
items: components["schemas"]["CoinSellerLedger"][];
page: number;
pageSize: number;
total: number;
};
ApiPageBDProfile: {
items: components["schemas"]["BDProfile"][];
page: number;
@ -4217,6 +4255,9 @@ export interface components {
ApiResponseCoinSeller: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CoinSeller"];
};
ApiResponseCoinSellerLedgerPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageCoinSellerLedger"];
};
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageBDProfile"];
};
@ -4244,6 +4285,70 @@ export interface components {
[key: string]: unknown;
};
};
ApiResponseSocialBiRequirements: components["schemas"]["Envelope"] & {
data?: components["schemas"]["SocialBiRequirementsResult"];
};
SocialBiRequirementsResult: {
apps: components["schemas"]["SocialBiRequirementsApp"][];
access?: components["schemas"]["SocialBiRequirementsAccess"];
};
SocialBiRequirementsApp: {
app_code: string;
app_name: string;
kind: string;
restricted?: boolean;
allowed_region_ids?: number[];
sections: components["schemas"]["SocialBiRequirementsSection"][];
/** Format: int64 */
updated_at_ms?: number;
error?: string;
};
SocialBiRequirementsSection: {
/** @enum {string} */
key: "new_users" | "revenue" | "retention" | "active" | "gift" | "game";
label?: string;
filters?: components["schemas"]["SocialBiRequirementsFilters"];
columns: components["schemas"]["SocialBiRequirementsColumn"][];
metric_definitions?: components["schemas"]["SocialBiRequirementsMetricDefinition"][];
total?: components["schemas"]["SocialBiRequirementsRow"];
daily_series: components["schemas"]["SocialBiRequirementsRow"][];
};
SocialBiRequirementsFilters: {
/** @enum {string} */
user_role?: "all" | "host" | "user";
/** @enum {string} */
payer_type?: "all" | "paid" | "unpaid";
};
SocialBiRequirementsColumn: {
key: string;
label: string;
/** @description count、ratio、money_minor、coin、duration_ms 或 number。 */
type: string;
tooltip?: string;
};
SocialBiRequirementsMetricDefinition: {
metric: string;
definition: string;
};
/** @description 日明细或汇总行;指标以 columns.key 对应的动态字段返回,兼容历史 metrics 对象。 */
SocialBiRequirementsRow: {
stat_day?: string;
label?: string;
metrics?: {
[key: string]: unknown;
};
} & {
[key: string]: unknown;
};
SocialBiRequirementsAccess: {
all?: boolean;
scopes?: components["schemas"]["SocialBiRequirementsScope"][];
};
SocialBiRequirementsScope: {
app_code?: string;
/** Format: int64 */
region_id?: number;
};
ApiResponseFinanceApplication: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceApplication"];
};
@ -4392,6 +4497,51 @@ export interface components {
userId: string;
username?: string;
};
CoinLedgerUser: {
avatar?: string;
displayUserId?: string;
userId: string;
username?: string;
};
CoinSellerLedger: {
/** Format: int64 */
amount: number;
/** Format: int64 */
availableDelta: number;
/** @enum {string} */
bizType: "coin_seller_stock_purchase" | "coin_seller_stock_deduction" | "coin_seller_recharge" | "coin_seller_coin_compensation" | "coin_seller_transfer" | "coin_seller_sub_transfer" | "salary_transfer_to_coin_seller" | "manual_credit";
commandId?: string;
counterpartyUserId?: string;
/** Format: int64 */
createdAtMs: number;
/** @enum {string} */
direction: "income" | "expense";
/** Format: int64 */
entryId: number;
/** @enum {string} */
ledgerType: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
metadata?: {
[key: string]: unknown;
};
operator?: {
adminId?: string;
name?: string;
username?: string;
};
operatorUserId?: string;
/** Format: int64 */
paidAmountMicro?: number;
paidCurrencyCode?: string;
reason?: string;
receiver: components["schemas"]["CoinLedgerUser"];
seller: components["schemas"]["CoinLedgerUser"];
/** Format: int64 */
sellerBalanceAfter: number;
stockType?: string;
transactionId: string;
/** Format: int64 */
transferUsdMinor?: number;
};
CreateCoinSellerPayload: {
canManageSubCoinSellers?: boolean;
commandId: string;
@ -4871,6 +5021,15 @@ export interface components {
};
};
/** @description OK */
CoinSellerLedgerPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCoinSellerLedgerPage"];
};
};
/** @description OK */
BDProfilePageResponse: {
headers: {
[name: string]: unknown;
@ -5150,6 +5309,15 @@ export interface components {
};
};
/** @description OK */
SocialBiRequirementsResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseSocialBiRequirements"];
};
};
/** @description OK */
TeamListResponse: {
headers: {
[name: string]: unknown;
@ -7594,14 +7762,56 @@ export interface operations {
};
listCoinSellerLedger: {
parameters: {
query?: never;
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
seller_user_id?: number;
seller_keyword?: string;
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CoinSellerLedgerPageResponse"];
};
};
exportCoinSellerLedger: {
parameters: {
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
seller_user_id?: number;
seller_keyword?: string;
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description CSV */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/csv": string;
};
};
};
};
createFullServerNoticeFanout: {
@ -8038,6 +8248,29 @@ export interface operations {
200: components["responses"]["StatisticsObjectResponse"];
};
};
getSocialBiRequirements: {
parameters: {
query?: {
stat_tz?: string;
start_ms?: number;
end_ms?: number;
app_codes?: string;
region_id?: number;
/** @description 逗号分隔的大区 ID用于 Social BI 顶栏多选区域。 */
region_ids?: string;
section?: "new_users" | "revenue" | "retention" | "active" | "gift" | "game";
user_role?: "all" | "host" | "user";
payer_type?: "all" | "paid" | "unpaid";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["SocialBiRequirementsResponse"];
};
};
getSocialBiFunnel: {
parameters: {
query?: {