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 = ( +
+
+ + + + + {requirementColumns.map((column) => ( + + ))} + + + + {requirementTotal ? ( + + + + {requirementColumns.map((column) => ( + + ))} + + ) : null} + {requirementRows.map((row, rowIndex) => ( + + + + {requirementColumns.map((column) => ( + + ))} + + ))} + +
日期App + + {column.label} + +
汇总全部{formatRequirementValue(column, requirementTotal[column.key])}
{row.stat_day || "--"}{requirementAppName(row)}{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} + + )}
); } + +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?: {