diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index e0b553a..4fdee98 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -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"],
diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx
index bf8e318..ae3cd07 100644
--- a/databi/src/DatabiApp.test.jsx
+++ b/databi/src/DatabiApp.test.jsx
@@ -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®ions=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();
+ 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({
diff --git a/databi/src/api.js b/databi/src/api.js
index 611535f..7f0997c 100644
--- a/databi/src/api.js
+++ b/databi/src/api.js
@@ -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) {
diff --git a/databi/src/api.test.js b/databi/src/api.test.js
index c2421ee..05395a3 100644
--- a/databi/src/api.test.js
+++ b/databi/src/api.test.js
@@ -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 })));
diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js
index bd3cf19..6f245b9 100644
--- a/databi/src/social/useSocialBiData.js
+++ b/databi/src/social/useSocialBiData.js
@@ -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;
+}
diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx
index e4479f8..2dcc997 100644
--- a/databi/src/social/views/DataTableView.jsx
+++ b/databi/src/social/views/DataTableView.jsx
@@ -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 = (
{SKELETON_WIDTHS.map((width) => (
@@ -167,25 +499,19 @@ export function DataTableView() {
);
} else if (!baseRows.length) {
- content = (
+ wideContent = (
- 当前维度暂无数据
-
- {dim.dims.includes("region")
- ? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
- : "试试更换日期区间、调整 App 筛选或切换其它维度"}
-
+ 当前无数据
);
} else if (!visibleMetrics.length) {
- content = (
+ wideContent = (
所有列组已隐藏
- 点击上方列组开关,恢复需要查看的指标列
);
} else {
- content = (
+ wideContent = (
@@ -258,22 +584,91 @@ export function DataTableView() {
);
}
+ let requirementsContent;
+ if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
+ requirementsContent = (
+
+ {SKELETON_WIDTHS.map((width) => (
+
+ ))}
+
+ );
+ } else if (requirementsError) {
+ requirementsContent = (
+
+ {requirementsError}
+
+ );
+ } else if (!requirementRows.length && !requirementTotal) {
+ requirementsContent = (
+
+ 当前无数据
+
+ );
+ } else {
+ requirementsContent = (
+
+
+
+
+ | 日期 |
+ App |
+ {requirementColumns.map((column) => (
+
+
+ {column.label}
+
+ |
+ ))}
+
+
+
+ {requirementTotal ? (
+
+ | 汇总 |
+ 全部 |
+ {requirementColumns.map((column) => (
+ {formatRequirementValue(column, requirementTotal[column.key])} |
+ ))}
+
+ ) : null}
+ {requirementRows.map((row, rowIndex) => (
+
+ | {row.stat_day || "--"} |
+ {requirementAppName(row)} |
+ {requirementColumns.map((column) => (
+ {formatRequirementValue(column, row[column.key])} |
+ ))}
+
+ ))}
+
+
+
+ );
+ }
+
+ 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 (
数据明细
- {rangeLabel(range)} · 共 {sortedRows.length} 行
+ {rangeLabel(range)} · 共 {rowCount} 行
-
- {DIMENSIONS.map((item) => (
+
+ {TABLE_MODES.map((item) => (
))}
-
- 列组
- {METRIC_GROUPS.map((group) => {
- const isOpen = !closedGroups.includes(group.key);
- return (
-
- );
- })}
-
- {hasRestricted ?
已按数据范围过滤 : null}
+ {mode === "wide" ? (
+ <>
+
+ {DIMENSIONS.map((item) => (
+
+ ))}
+
+
+ 列组
+ {METRIC_GROUPS.map((group) => {
+ const isOpen = !closedGroups.includes(group.key);
+ return (
+
+ );
+ })}
+
+ {hasRestricted ?
已按数据范围过滤 : null}
+ >
+ ) : (
+ <>
+
+ {REQUIREMENT_SECTIONS.map((section) => (
+
+ ))}
+
+ {activeRequirementSection.roleFilter ? (
+
+ ) : null}
+ {activeRequirementSection.payerFilter ? (
+
+ ) : null}
+ >
+ )}
}
variant="outlined"
@@ -316,3 +764,25 @@ export function DataTableView() {
);
}
+
+function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
+ return (
+
+
{label}
+
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
diff --git a/databi/src/social/views/data-table-view.css b/databi/src/social/views/data-table-view.css
index bfa79ba..f09f808 100644
--- a/databi/src/social/views/data-table-view.css
+++ b/databi/src/social/views/data-table-view.css
@@ -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;
}
diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts
index 2bc5a7c..c481214 100644
--- a/src/features/operations/api.test.ts
+++ b/src/features/operations/api.test.ts
@@ -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");
});
diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts
index b9ac0d5..d9f9f8a 100644
--- a/src/features/operations/api.ts
+++ b/src/features/operations/api.ts
@@ -141,8 +141,9 @@ export function listCoinSellerLedger(query: PageQuery = {}): Promise
{
- 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,
});
diff --git a/src/features/operations/components/CoinSellerLedgerTable.jsx b/src/features/operations/components/CoinSellerLedgerTable.jsx
index a14da69..d123960 100644
--- a/src/features/operations/components/CoinSellerLedgerTable.jsx
+++ b/src/features/operations/components/CoinSellerLedgerTable.jsx
@@ -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: "工资转币商",
diff --git a/src/features/operations/components/CoinSellerLedgerTable.test.jsx b/src/features/operations/components/CoinSellerLedgerTable.test.jsx
new file mode 100644
index 0000000..7113b6a
--- /dev/null
+++ b/src/features/operations/components/CoinSellerLedgerTable.test.jsx
@@ -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();
+
+ 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",
+ };
+}
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts
index e096728..164996d 100644
--- a/src/shared/api/generated/endpoints.ts
+++ b/src/shared/api/generated/endpoints.ts
@@ -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 = {
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 = {
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,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index f9a1a6a..055607a 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -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?: {