diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 2f6df9d..b5f3aab 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -3987,7 +3987,13 @@ { "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_id", "schema": { "type": "integer", "format": "int64" } }, + { + "in": "query", + "name": "region_ids", + "description": "逗号分隔的大区 ID;显式区域筛选时用于按 Finance 口径补逐日渠道。", + "schema": { "type": "string" } + } ], "responses": { "200": { @@ -4447,6 +4453,21 @@ "x-permissions": ["finance-order:coin-seller-recharge:create"] } }, + "/admin/finance/orders/coin-seller-recharges/quotes": { + "post": { + "operationId": "quoteFinanceCoinSellerRechargeOrder", + "requestBody": { + "$ref": "#/components/requestBodies/FinanceCoinSellerRechargeQuoteRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/FinanceCoinSellerRechargeQuoteResponse" + } + }, + "x-permission": "finance-order:coin-seller-recharge:create", + "x-permissions": ["finance-order:coin-seller-recharge:create"] + } + }, "/admin/finance/orders/coin-seller-recharges/receipt-verifications": { "post": { "operationId": "verifyFinanceCoinSellerRechargeReceipt", @@ -4506,6 +4527,51 @@ "x-permissions": ["finance-order:coin-seller-recharge:grant"] } }, + "/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": { + "get": { + "operationId": "getFinanceCoinSellerRechargeExchangeRate", + "parameters": [ + { + "in": "path", + "name": "app_code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/FinanceCoinSellerRechargeExchangeRateResponse" + } + }, + "x-permission": "coin-seller:exchange-rate", + "x-permissions": ["coin-seller:exchange-rate"] + }, + "put": { + "operationId": "replaceFinanceCoinSellerRechargeExchangeRate", + "parameters": [ + { + "in": "path", + "name": "app_code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/FinanceCoinSellerRechargeExchangeRateRequest" + }, + "responses": { + "200": { + "$ref": "#/components/responses/FinanceCoinSellerRechargeExchangeRateResponse" + } + }, + "x-permission": "coin-seller:exchange-rate", + "x-permissions": ["coin-seller:exchange-rate"] + } + }, "/admin/payment/temporary-links": { "get": { "operationId": "listTemporaryPaymentLinks", @@ -6021,6 +6087,54 @@ "x-permissions": ["overview:view"] } }, + "/dashboard/user-profile-overview": { + "get": { + "operationId": "dashboardUserProfileOverview", + "x-permission": "overview:view", + "parameters": [ + { + "in": "query", + "name": "app_code", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "stat_tz", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "start_ms", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "in": "query", + "name": "end_ms", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/DashboardUserProfileOverviewResponse" + } + }, + "x-permissions": ["overview:view"] + } + }, "/statistics/overview": { "get": { "operationId": "statisticsOverview", @@ -7256,6 +7370,24 @@ } } }, + "FinanceCoinSellerRechargeQuoteRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeQuoteInput" + } + } + } + }, + "FinanceCoinSellerRechargeExchangeRateRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateInput" + } + } + } + }, "FinanceCoinSellerRechargeReceiptVerificationRequest": { "content": { "application/json": { @@ -7675,6 +7807,16 @@ } } }, + "DashboardUserProfileOverviewResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseDashboardUserProfileOverview" + } + } + } + }, "VipProgramResponse": { "description": "OK", "content": { @@ -7755,6 +7897,26 @@ } } }, + "FinanceCoinSellerRechargeQuoteResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeQuote" + } + } + } + }, + "FinanceCoinSellerRechargeExchangeRateResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeExchangeRate" + } + } + } + }, "FinanceCoinSellerRechargeReceiptVerificationResponse": { "description": "OK", "content": { @@ -9470,6 +9632,21 @@ } ] }, + "ApiResponseDashboardUserProfileOverview": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/DashboardUserProfileOverview" + } + } + } + ] + }, "ApiResponseEmpty": { "$ref": "#/components/schemas/Envelope" }, @@ -9687,6 +9864,36 @@ } ] }, + "ApiResponseFinanceCoinSellerRechargeQuote": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeQuote" + } + } + } + ] + }, + "ApiResponseFinanceCoinSellerRechargeExchangeRate": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRate" + } + } + } + ] + }, "ApiResponseFinanceCoinSellerRechargeReceiptVerification": { "allOf": [ { @@ -10829,6 +11036,70 @@ "type": "object", "additionalProperties": true }, + "DashboardUserDistributionItem": { + "type": "object", + "required": ["key", "label", "count"], + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + }, + "DashboardUserProfileOverview": { + "type": "object", + "required": [ + "appCode", + "totalUsers", + "newUsers", + "activeUsers", + "genderDistribution", + "appVersionDistribution", + "updatedAtMs" + ], + "properties": { + "appCode": { + "type": "string" + }, + "totalUsers": { + "type": "integer", + "minimum": 0, + "description": "已完成资料且排除机器人、快捷账号的自然用户总数" + }, + "newUsers": { + "type": "integer", + "minimum": 0 + }, + "activeUsers": { + "type": "integer", + "minimum": 0 + }, + "genderDistribution": { + "type": "array", + "description": "已完成资料且排除机器人、快捷账号的自然用户性别分布", + "items": { + "$ref": "#/components/schemas/DashboardUserDistributionItem" + } + }, + "appVersionDistribution": { + "type": "array", + "description": "自然用户最近一次成功登录时上报的 App 版本号分布", + "items": { + "$ref": "#/components/schemas/DashboardUserDistributionItem" + } + }, + "updatedAtMs": { + "type": "integer", + "format": "int64" + } + } + }, "Envelope": { "type": "object", "properties": { @@ -11846,7 +12117,7 @@ }, "FinanceCoinSellerRechargeOrderInput": { "type": "object", - "required": ["appCode", "targetUserId", "usdAmount", "coinAmount", "providerCode", "externalOrderNo"], + "required": ["appCode", "targetUserId", "usdAmount", "providerCode", "externalOrderNo"], "properties": { "appCode": { "type": "string" @@ -11858,9 +12129,9 @@ "type": "number", "minimum": 0 }, - "coinAmount": { - "type": "integer", - "minimum": 0 + "isMakeup": { + "type": "boolean", + "description": "补单时为 true;订单正常计入充值,但服务端强制金币数为 0" }, "providerCode": { "type": "string", @@ -11886,6 +12157,154 @@ } } }, + "FinanceCoinSellerRechargeQuoteInput": { + "type": "object", + "required": ["appCode", "targetUserId", "usdAmount"], + "properties": { + "appCode": { + "type": "string" + }, + "targetUserId": { + "type": "string" + }, + "usdAmount": { + "type": "number", + "exclusiveMinimum": 0 + } + } + }, + "FinanceCoinSellerRechargeQuote": { + "type": "object", + "required": ["appCode", "targetUserId", "usdAmount", "coinAmount", "coinsPerUsd", "rateSource"], + "properties": { + "appCode": { + "type": "string" + }, + "targetUserId": { + "type": "string" + }, + "targetDisplayUserId": { + "type": "string" + }, + "usdAmount": { + "type": "string" + }, + "coinAmount": { + "type": "integer" + }, + "coinsPerUsd": { + "type": "integer" + }, + "rateSource": { + "type": "string", + "enum": ["tier", "whitelist"] + }, + "matchedUserId": { + "type": "string" + }, + "minUsdAmount": { + "type": "string" + }, + "maxUsdAmount": { + "type": "string" + } + } + }, + "FinanceCoinSellerRechargeExchangeRateTier": { + "type": "object", + "required": ["minUsdAmount", "maxUsdAmount", "coinsPerUsd"], + "properties": { + "minUsdAmount": { + "type": "string" + }, + "maxUsdAmount": { + "type": "string" + }, + "coinsPerUsd": { + "type": "integer" + } + } + }, + "FinanceCoinSellerRechargeExchangeRateWhitelist": { + "type": "object", + "required": ["userId", "coinsPerUsd"], + "properties": { + "userId": { + "type": "string" + }, + "coinsPerUsd": { + "type": "integer" + } + } + }, + "FinanceCoinSellerRechargeExchangeRate": { + "type": "object", + "required": ["appCode", "tiers", "whitelist", "updatedAtMs"], + "properties": { + "appCode": { + "type": "string" + }, + "tiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateTier" + } + }, + "whitelist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FinanceCoinSellerRechargeExchangeRateWhitelist" + } + }, + "updatedAtMs": { + "type": "integer" + } + } + }, + "FinanceCoinSellerRechargeExchangeRateInput": { + "type": "object", + "required": ["tiers", "whitelist"], + "properties": { + "tiers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["minUsdAmount", "maxUsdAmount", "coinsPerUsd"], + "properties": { + "minUsdAmount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maxUsdAmount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "coinsPerUsd": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "whitelist": { + "type": "array", + "items": { + "type": "object", + "required": ["userId", "coinsPerUsd"], + "properties": { + "userId": { + "type": "string" + }, + "coinsPerUsd": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + }, "FinanceCoinSellerRechargeReceiptVerification": { "type": "object", "required": ["verified"], diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 1b6557a..3bfe215 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -335,7 +335,7 @@ test("routes databi social page to Social BI and loads real overview rows", asyn expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1); expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); expect(fetchSocialBiFunnel).not.toHaveBeenCalled(); - expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); + expect(fetchSocialBiKpi).not.toHaveBeenCalled(); // 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、 // App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。 @@ -359,7 +359,10 @@ test("routes databi social page to Social BI and loads real overview rows", asyn await act(async () => { within(viewTabs).getByRole("tab", { name: "运营中心" }).click(); }); + await flushEffects(); expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true"); + expect(fetchSocialBiKpi).toHaveBeenCalledTimes(1); + expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" })); expect(screen.getAllByText("Omar").length).toBeGreaterThan(0); expect(screen.getAllByText("$900").length).toBeGreaterThan(0); }); @@ -419,6 +422,9 @@ test("renders social BI data requirements table with section filters and CSV exp render(); await flushEffects(); + expect(fetchSocialBiOverview).toHaveBeenCalledWith( + expect.objectContaining({ appCodes: ["lalu"], regionIds: [9, 11], statTz: "Asia/Shanghai" }) + ); const viewTabs = screen.getByRole("tablist", { name: "数据视图" }); expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true"); diff --git a/databi/src/api.js b/databi/src/api.js index 37f7ebc..d44717c 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -160,7 +160,7 @@ export async function fetchSocialBiMaster() { return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiMaster)); } -export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs, statTz }) { +export async function fetchSocialBiOverview({ appCodes, endMs, regionId, regionIds, startMs, statTz }) { const query = {}; if (statTz) { query.stat_tz = statTz; @@ -176,6 +176,8 @@ export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs } if (regionId) { query.region_id = String(regionId); + } else if (regionIds?.length) { + query.region_ids = regionIds.join(","); } return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query }); } diff --git a/databi/src/api.test.js b/databi/src/api.test.js index 05395a3..62d7beb 100644 --- a/databi/src/api.test.js +++ b/databi/src/api.test.js @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { fetchFilterOptions, fetchSocialBiFilterOptions, + fetchSocialBiOverview, fetchSocialBiRequirements, fetchStatisticsOverview, getCurrentAppCode, @@ -120,6 +121,23 @@ test("loads social BI requirements with section role and payer filters", async ( expect(data.total.recharge_users).toBe(2); }); +test("loads social BI overview with an explicit multi-region Finance scope", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ apps: [] }))); + + await fetchSocialBiOverview({ + appCodes: ["aslan"], + endMs: 20, + regionIds: [9, 11], + startMs: 10, + statTz: "Asia/Shanghai" + }); + + const requestURL = new URL(String(fetch.mock.calls[0][0])); + expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/overview"); + expect(requestURL.searchParams.get("app_codes")).toBe("aslan"); + expect(requestURL.searchParams.get("region_ids")).toBe("9,11"); +}); + 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/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index e08f91b..1bf3dec 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -53,7 +53,10 @@ export function SocialBiApp() { const initial = useMemo(() => readStateFromURL(), []); const [filters, setFilters] = useState(initial.filters); const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview")); - const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel" }); + const data = useSocialBiData(filters, { + includeFunnel: viewKey === "funnel", + includeKpi: viewKey === "team" + }); useEffect(() => { writeStateToURL(filters, viewKey); diff --git a/databi/src/social/metrics.js b/databi/src/social/metrics.js index 3c02921..eb06fc7 100644 --- a/databi/src/social/metrics.js +++ b/databi/src/social/metrics.js @@ -8,11 +8,19 @@ export const METRIC_DEFS = { active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" }, paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" }, recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" }, - recharge_usd_minor: { label: "充值 ($)", tooltip: "全部渠道充值合计(USD)", type: "money" }, - new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "当日注册用户当日的充值金额", type: "money" }, - google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Google Play 渠道充值", type: "money" }, - mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "非谷歌的三方渠道充值合计", type: "money" }, - coin_seller_recharge_usd_minor: { label: "币商充值", tooltip: "币商进货充值金额", type: "money" }, + recharge_usd_minor: { + label: "充值 ($)", + tooltip: "Finance 口径成功充值合计(谷歌 + 非谷歌三方 + 币商,USD)", + type: "money" + }, + new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "Finance 暂无新用户充值拆分,当前不展示", type: "money" }, + google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Finance 口径 Google 成功充值", type: "money" }, + mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "Finance 口径非 Google 三方成功充值", type: "money" }, + coin_seller_recharge_usd_minor: { + label: "币商充值", + tooltip: "Finance 口径已核验币商充值订单 + H5 币商身份成功充值", + type: "money" + }, coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" }, coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币(legacy 按 SELLER_AGENT 流水)", type: "coin" }, game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" }, @@ -33,8 +41,8 @@ export const METRIC_DEFS = { salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" }, avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" }, mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" }, - arpu_usd_minor: { label: "ARPU ($)", tooltip: "充值 / 日活", type: "money" }, - arppu_usd_minor: { label: "ARPPU ($)", tooltip: "充值 / 付费用户", type: "money" }, + arpu_usd_minor: { label: "ARPU ($)", tooltip: "Finance 充值没有同口径活跃人数,当前不展示", type: "money" }, + arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" }, paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" }, recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" }, d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" }, @@ -201,6 +209,10 @@ const SNAPSHOT_METRIC_KEYS = new Set(["coin_total", "salary_usd_minor"]); // 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。 export function aggregateMetricMaps(rows, { acrossTime = false } = {}) { const out = {}; + // Finance 币商/H5 金额没有与旧 paid_users 一致的去重人数;标记需跨聚合传播, + // 避免前端把精确金额再次除以旧人数,重新制造混合口径 ARPU/ARPPU。 + const financeRechargeAligned = rows.some((row) => row?.finance_recharge_aligned === true); + out.finance_recharge_aligned = financeRechargeAligned; let latestDay = ""; if (acrossTime) { rows.forEach((row) => { @@ -228,8 +240,8 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) { const recharge = out.recharge_usd_minor; const active = out.active_users; const paid = out.paid_users; - out.arpu_usd_minor = recharge !== null && active ? Math.round(recharge / active) : null; - out.arppu_usd_minor = recharge !== null && paid ? Math.round(recharge / paid) : null; + out.arpu_usd_minor = !financeRechargeAligned && recharge !== null && active ? Math.round(recharge / active) : null; + out.arppu_usd_minor = !financeRechargeAligned && recharge !== null && paid ? Math.round(recharge / paid) : null; out.paid_conversion_rate = paid !== null && active ? paid / active : null; out.recharge_conversion_rate = out.recharge_users !== null && active ? out.recharge_users / active : null; out.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null; diff --git a/databi/src/social/metrics.test.js b/databi/src/social/metrics.test.js new file mode 100644 index 0000000..8bfc880 --- /dev/null +++ b/databi/src/social/metrics.test.js @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { aggregateMetricMaps, metricTooltip } from "./metrics.js"; + +test("describes recharge metrics with the Finance source of truth", () => { + expect(metricTooltip("recharge_usd_minor")).toContain("Finance 口径"); + expect(metricTooltip("google_recharge_usd_minor")).toContain("Google 成功充值"); + expect(metricTooltip("mifapay_recharge_usd_minor")).toContain("非 Google 三方成功充值"); + expect(metricTooltip("coin_seller_recharge_usd_minor")).toContain("已核验币商充值订单 + H5 币商身份成功充值"); + expect(metricTooltip("arppu_usd_minor")).toContain("当前不展示"); +}); + +test("keeps ARPU and ARPPU unavailable after aggregating Finance-aligned recharge", () => { + const metrics = aggregateMetricMaps([ + { active_users: 10, finance_recharge_aligned: true, paid_users: 2, recharge_usd_minor: 600 }, + { active_users: 5, finance_recharge_aligned: true, paid_users: 1, recharge_usd_minor: 300 } + ]); + + expect(metrics.recharge_usd_minor).toBe(900); + expect(metrics.finance_recharge_aligned).toBe(true); + expect(metrics.arpu_usd_minor).toBeNull(); + expect(metrics.arppu_usd_minor).toBeNull(); +}); diff --git a/databi/src/social/useSocialBiData.js b/databi/src/social/useSocialBiData.js index a243853..07d68bb 100644 --- a/databi/src/social/useSocialBiData.js +++ b/databi/src/social/useSocialBiData.js @@ -3,13 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js"; -import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js"; +import { DEFAULT_TIME_ZONE, rangeExclusiveEndMs, rangeStartMs } from "../utils/time.js"; import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js"; export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE; export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"]; -export function useSocialBiData(filters, { includeFunnel = false } = {}) { +export function useSocialBiData(filters, { includeFunnel = false, includeKpi = false } = {}) { const [master, setMaster] = useState(null); // 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。 const [isMasterSettled, setIsMasterSettled] = useState(false); @@ -69,6 +69,10 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) { const selected = filters.apps.filter((appCode) => available.includes(appCode)); return selected.length ? selected : available; }, [filters.apps, master]); + const overviewRequestScopes = useMemo( + () => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []), + [appCodes, filters.regions, master?.regions] + ); useEffect(() => { if (!isMasterSettled) { @@ -76,17 +80,28 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) { } const sequence = ++requestSeq.current; const startMs = rangeStartMs(range, SOCIAL_BI_TZ); - const endMs = rangeEndMs(range, SOCIAL_BI_TZ); + const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ); setIsLoading(true); // 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。 const funnelRequest = includeFunnel ? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); - Promise.allSettled([ - fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }), - funnelRequest, - fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) - ]).then(([overviewResult, funnelResult, kpiResult]) => { + // KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。 + const kpiRequest = includeKpi ? fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }) : Promise.resolve(null); + // 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。 + const overviewRequest = overviewRequestScopes.length + ? Promise.all( + overviewRequestScopes.map((scope) => + fetchSocialBiOverview({ + ...scope, + endMs, + startMs, + statTz: SOCIAL_BI_TZ + }) + ) + ).then(mergeOverviewResponses) + : Promise.resolve({ access: master?.access || null, apps: [] }); + Promise.allSettled([overviewRequest, funnelRequest, kpiRequest]).then(([overviewResult, funnelResult, kpiResult]) => { if (sequence !== requestSeq.current) { return; } @@ -115,7 +130,9 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) { setFunnel(null); nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`); } - if (kpiResult.status === "fulfilled") { + if (!includeKpi) { + setKpi(null); + } else if (kpiResult.status === "fulfilled") { setKpi(kpiResult.value); Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => { nextErrors.push(`${appCode}: ${message}`); @@ -127,7 +144,7 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) { setErrors(nextErrors); setIsLoading(false); }); - }, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]); + }, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]); const loadRequirements = useCallback( ({ newUserType, payerType, section, userRole } = {}) => { @@ -136,7 +153,7 @@ export function useSocialBiData(filters, { includeFunnel = false } = {}) { } const sequence = ++requirementsSeq.current; const startMs = rangeStartMs(range, SOCIAL_BI_TZ); - const endMs = rangeEndMs(range, SOCIAL_BI_TZ); + const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ); const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions); setIsRequirementsLoading(true); setRequirementsError(""); @@ -353,6 +370,81 @@ function buildRequirementRequestScopes(appCodes, regions) { return scopes; } +function buildOverviewRequestScopes(appCodes, regions, masterRegions) { + const selectedApps = new Set((appCodes || []).filter(Boolean)); + if (!regions?.length || regions.includes(ALL)) { + return [{ appCodes: [...selectedApps] }]; + } + const configuredByApp = new Map(); + (masterRegions || []).forEach((region) => { + const appCode = String(region?.app_code || "") + .trim() + .toLowerCase(); + const regionID = Number(region?.region_id); + if (!appCode || !Number.isFinite(regionID) || regionID <= 0) { + return; + } + if (!configuredByApp.has(appCode)) { + configuredByApp.set(appCode, new Set()); + } + configuredByApp.get(appCode).add(regionID); + }); + + 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, new Set(configuredByApp.get(appCode) || [])); + } + return; + } + const [rawAppCode, rawRegionID] = value.split(":"); + const appCode = String(rawAppCode || "") + .trim() + .toLowerCase(); + const regionID = Number(rawRegionID); + if (!appCode || !Number.isFinite(regionID) || regionID <= 0) { + 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; + } + 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 }); + } else { + // 区域目录暂不可用时仍限制到该 App,后端会按权限返回可见范围。 + scopes.push({ appCodes: [appCode] }); + } + }); + return scopes; +} + +function mergeOverviewResponses(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; +} + function mergeRequirementResponses(results) { const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] }; results.forEach((item) => { diff --git a/databi/src/utils/time.js b/databi/src/utils/time.js index b00c977..fbcb9ad 100644 --- a/databi/src/utils/time.js +++ b/databi/src/utils/time.js @@ -63,6 +63,13 @@ export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) { return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone); } +// Finance/Statistics 查询统一使用 [start_ms, end_ms);筛选器只有秒精度, +// 因此把可见结束秒推进 1 秒作为排他上界,避免漏掉结束日 23:59:59.xxx 的账单。 +export function rangeExclusiveEndMs(range, timeZone = DEFAULT_TIME_ZONE) { + const endMs = rangeEndMs(range, timeZone); + return endMs === "" ? "" : Number(endMs) + 1000; +} + export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) { return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone); } diff --git a/databi/src/utils/time.test.js b/databi/src/utils/time.test.js index 9e28012..6a6caa1 100644 --- a/databi/src/utils/time.test.js +++ b/databi/src/utils/time.test.js @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { rangeEndMs, rangeStartMs } from "./time.js"; +import { rangeEndMs, rangeExclusiveEndMs, rangeStartMs } from "./time.js"; test("converts the selected date range with UTC boundaries", () => { const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" }; @@ -14,3 +14,10 @@ test("converts the selected date range with Beijing time boundaries", () => { expect(rangeStartMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 5, 16, 0, 0)); expect(rangeEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 15, 59, 59)); }); + +test("uses the next second as the exclusive Finance query boundary", () => { + const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" }; + + expect(rangeExclusiveEndMs(range, "UTC")).toBe(Date.UTC(2026, 5, 7, 0, 0, 0)); + expect(rangeExclusiveEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 16, 0, 0)); +}); diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index da92019..51b25d3 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -15,6 +15,7 @@ import { fetchFinanceSession, filterOperationsByPermissions, grantFinanceCoinSellerRechargeOrder, + getFinanceCoinSellerRechargeExchangeRate, listFinanceApplications, listFinanceCoinSellerRechargeOrders, listFinanceRechargeDetails, @@ -22,8 +23,10 @@ import { listMyFinanceApplications, listFinanceWithdrawalApplications, refreshFinanceGoogleRechargePaid, + replaceFinanceCoinSellerRechargeExchangeRate, rejectFinanceApplication, rejectFinanceWithdrawalApplication, + quoteFinanceCoinSellerRechargeOrder, verifyFinanceCoinSellerRechargeReceipt, verifyFinanceCoinSellerRechargeOrder, } from "./api.js"; @@ -660,6 +663,25 @@ export function FinanceApp() { } }; + const quoteCoinSellerRechargeOrder = useCallback( + (payload) => quoteFinanceCoinSellerRechargeOrder(cleanPayload(payload)), + [], + ); + + const loadCoinSellerRechargeExchangeRate = useCallback( + (appCode) => getFinanceCoinSellerRechargeExchangeRate(appCode), + [], + ); + + const saveCoinSellerRechargeExchangeRate = useCallback( + async (appCode, payload) => { + const saved = await replaceFinanceCoinSellerRechargeExchangeRate(appCode, cleanPayload(payload)); + showToast("金币汇率配置已保存", "success"); + return saved; + }, + [showToast], + ); + const verifyCoinSellerRechargeReceipt = async (payload) => { if (!session?.canVerifyCoinSellerRechargeOrder) { showToast("无校验币商充值订单权限", "error"); @@ -874,6 +896,7 @@ export function FinanceApp() { actionLoading={coinSellerRechargeOrderActionLoading} apps={rechargeApps} canCreate={session.canCreateCoinSellerRechargeOrder} + canConfigureExchangeRate={session.canConfigureCoinSellerRechargeExchangeRate} canGrant={session.canGrantCoinSellerRechargeOrder} canVerify={session.canVerifyCoinSellerRechargeOrder} error={coinSellerRechargeOrdersState.error} @@ -881,9 +904,12 @@ export function FinanceApp() { loading={coinSellerRechargeOrdersState.loading} orders={coinSellerRechargeOrdersState.data} onCreate={submitCoinSellerRechargeOrder} + onLoadExchangeRate={loadCoinSellerRechargeExchangeRate} onFiltersChange={updateCoinSellerRechargeOrderFilters} onGrant={grantCoinSellerRechargeOrder} onReload={loadCoinSellerRechargeOrders} + onQuote={quoteCoinSellerRechargeOrder} + onSaveExchangeRate={saveCoinSellerRechargeExchangeRate} onVerify={verifyCoinSellerRechargeOrder} onVerifyReceipt={verifyCoinSellerRechargeReceipt} /> diff --git a/finance/src/FinanceApp.test.jsx b/finance/src/FinanceApp.test.jsx index 64edab5..9f1e76d 100644 --- a/finance/src/FinanceApp.test.jsx +++ b/finance/src/FinanceApp.test.jsx @@ -30,6 +30,7 @@ vi.mock("./api.js", () => ({ fetchFinanceSession: vi.fn(), filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))), grantFinanceCoinSellerRechargeOrder: vi.fn(), + getFinanceCoinSellerRechargeExchangeRate: vi.fn(), listFinanceApplications: vi.fn(), listFinanceCoinSellerRechargeOrders: vi.fn(), listFinanceRechargeDetails: vi.fn(), @@ -39,12 +40,16 @@ vi.mock("./api.js", () => ({ rejectFinanceApplication: vi.fn(), rejectFinanceWithdrawalApplication: vi.fn(), refreshFinanceGoogleRechargePaid: vi.fn(), - verifyFinanceCoinSellerRechargeOrder: vi.fn() + replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(), + quoteFinanceCoinSellerRechargeOrder: vi.fn(), + verifyFinanceCoinSellerRechargeOrder: vi.fn(), + verifyFinanceCoinSellerRechargeReceipt: vi.fn() })); const defaultSession = { canAccessFinance: true, canAuditApplication: false, + canConfigureCoinSellerRechargeExchangeRate: false, canCreateApplication: true, canCreateCoinSellerRechargeOrder: false, canGrantCoinSellerRechargeOrder: false, diff --git a/finance/src/api.js b/finance/src/api.js index f699f46..fa2fc3f 100644 --- a/finance/src/api.js +++ b/finance/src/api.js @@ -30,6 +30,7 @@ export async function fetchFinanceSession() { const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder); const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder); const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder); + const canConfigureCoinSellerRechargeExchangeRate = permissions.includes(FINANCE_PERMISSIONS.configureCoinSellerRechargeExchangeRate); return { ...session, @@ -41,6 +42,7 @@ export async function fetchFinanceSession() { canViewCoinSellerRechargeOrders, canAuditApplication, canAuditWithdrawalApplications, + canConfigureCoinSellerRechargeExchangeRate, canCreateApplication, canCreateCoinSellerRechargeOrder, canGrantCoinSellerRechargeOrder, @@ -114,6 +116,29 @@ export async function createFinanceCoinSellerRechargeOrder(payload) { return normalizeCoinSellerRechargeOrder(data); } +export async function quoteFinanceCoinSellerRechargeOrder(payload) { + const endpoint = API_ENDPOINTS.quoteFinanceCoinSellerRechargeOrder; + return apiRequest(apiEndpointPath(API_OPERATIONS.quoteFinanceCoinSellerRechargeOrder), { + body: cleanPayload(payload), + method: endpoint.method + }); +} + +export async function getFinanceCoinSellerRechargeExchangeRate(appCode) { + const endpoint = API_ENDPOINTS.getFinanceCoinSellerRechargeExchangeRate; + return apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), { + method: endpoint.method + }); +} + +export async function replaceFinanceCoinSellerRechargeExchangeRate(appCode, payload) { + const endpoint = API_ENDPOINTS.replaceFinanceCoinSellerRechargeExchangeRate; + return apiRequest(apiEndpointPath(API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), { + body: cleanPayload(payload), + method: endpoint.method + }); +} + export async function verifyFinanceCoinSellerRechargeReceipt(payload) { const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeReceipt; return apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt), { diff --git a/finance/src/api.test.js b/finance/src/api.test.js index 9133dd7..2638896 100644 --- a/finance/src/api.test.js +++ b/finance/src/api.test.js @@ -6,6 +6,7 @@ import { createFinanceApplication, createFinanceCoinSellerRechargeOrder, fetchFinanceApplicationOptions, + getFinanceCoinSellerRechargeExchangeRate, filterOperationsByPermissions, grantFinanceCoinSellerRechargeOrder, listFinanceApplications, @@ -15,6 +16,8 @@ import { listFinanceWithdrawalApplications, rejectFinanceApplication, rejectFinanceWithdrawalApplication, + quoteFinanceCoinSellerRechargeOrder, + replaceFinanceCoinSellerRechargeExchangeRate, verifyFinanceCoinSellerRechargeReceipt, verifyFinanceCoinSellerRechargeOrder } from "./api.js"; @@ -111,7 +114,6 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy await listFinanceCoinSellerRechargeOrders({ page: 1, page_size: 50, provider_code: "mifapay" }); await createFinanceCoinSellerRechargeOrder({ appCode: "lalu", - coinAmount: 100000, externalOrderNo: "MIFA-001", providerAmountMinor: 125050, providerCode: "mifapay", @@ -138,7 +140,6 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy expect(calls[1][1]?.method).toBe("POST"); expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ appCode: "lalu", - coinAmount: 100000, externalOrderNo: "MIFA-001", providerAmountMinor: 125050, providerCode: "mifapay", @@ -162,6 +163,29 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy expect(calls[4][1]?.method).toBe("POST"); }); +test("finance coin seller quote and exchange rate APIs use generated endpoint paths", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { appCode: "lalu", tiers: [], whitelist: [] } }))) + ); + + await quoteFinanceCoinSellerRechargeOrder({ appCode: "lalu", targetUserId: "123456", usdAmount: 150 }); + await getFinanceCoinSellerRechargeExchangeRate("lalu"); + await replaceFinanceCoinSellerRechargeExchangeRate("lalu", { + tiers: [{ minUsdAmount: 100, maxUsdAmount: 200, coinsPerUsd: 80000 }], + whitelist: [{ userId: "123456", coinsPerUsd: 100000 }] + }); + + const calls = vi.mocked(fetch).mock.calls; + expect(String(calls[0][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/quotes"); + expect(calls[0][1]?.method).toBe("POST"); + expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ appCode: "lalu", targetUserId: "123456", usdAmount: 150 }); + expect(String(calls[1][0])).toContain("/api/v1/admin/finance/coin-seller-recharge-exchange-rates/lalu"); + expect(calls[1][1]?.method).toBe("GET"); + expect(String(calls[2][0])).toContain("/api/v1/admin/finance/coin-seller-recharge-exchange-rates/lalu"); + expect(calls[2][1]?.method).toBe("PUT"); +}); + test("filters operation options by backend-configured permissions", () => { const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [ "finance-operation:user-coin-credit", diff --git a/finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx b/finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx new file mode 100644 index 0000000..b229dd1 --- /dev/null +++ b/finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx @@ -0,0 +1,245 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + buildCoinSellerRechargeExchangeRatePayload, + validateCoinSellerRechargeExchangeRatePayload, +} from "../format.js"; + +const EMPTY_CONFIG = { tiers: [], whitelist: [] }; + +export function FinanceCoinSellerExchangeRateDialog({ apps, initialAppCode, open, onClose, onLoad, onSave }) { + const rowSequence = useRef(0); + const [appCode, setAppCode] = useState(""); + const [config, setConfig] = useState(EMPTY_CONFIG); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setAppCode(initialAppCode || apps[0]?.appCode || ""); + setError(""); + } + }, [apps, initialAppCode, open]); + + useEffect(() => { + if (!open || !appCode) { + return undefined; + } + let active = true; + setLoading(true); + setError(""); + onLoad(appCode) + .then((data) => { + if (!active) { + return; + } + setConfig({ + tiers: (data?.tiers || []).map((item) => withRowID(item, rowSequence)), + whitelist: (data?.whitelist || []).map((item) => withRowID(item, rowSequence)), + }); + }) + .catch((loadError) => { + if (active) { + setConfig(EMPTY_CONFIG); + setError(loadError.message || "加载金币汇率配置失败"); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [appCode, onLoad, open]); + + const payload = useMemo(() => buildCoinSellerRechargeExchangeRatePayload(config), [config]); + + const submit = async (event) => { + event.preventDefault(); + const validationMessage = validateCoinSellerRechargeExchangeRatePayload(payload); + if (validationMessage) { + setError(validationMessage); + return; + } + setSaving(true); + setError(""); + try { + await onSave(appCode, payload); + onClose(); + } catch (saveError) { + setError(saveError.message || "保存金币汇率配置失败"); + } finally { + setSaving(false); + } + }; + + const updateRow = (group, id, key, value) => { + setConfig((current) => ({ + ...current, + [group]: current[group].map((item) => (item.id === id ? { ...item, [key]: value } : item)), + })); + setError(""); + }; + + const addRow = (group) => { + const item = + group === "tiers" + ? { coinsPerUsd: "", maxUsdAmount: "", minUsdAmount: "" } + : { coinsPerUsd: "", userId: "" }; + setConfig((current) => ({ ...current, [group]: [...current[group], withRowID(item, rowSequence)] })); + setError(""); + }; + + const removeRow = (group, id) => { + setConfig((current) => ({ ...current, [group]: current[group].filter((item) => item.id !== id) })); + setError(""); + }; + + const disabled = loading || saving; + + return ( + + 币商充值金币汇率 + +
+ {error ? {error} : null} + setAppCode(event.target.value)} + > + {apps.map((app) => ( + + {app.appName || app.appCode} + + ))} + + + addRow("tiers")} + onRemove={(id) => removeRow("tiers", id)} + renderRow={(item) => ( + <> + updateRow("tiers", item.id, "minUsdAmount", event.target.value)} + /> + updateRow("tiers", item.id, "maxUsdAmount", event.target.value)} + /> + updateRow("tiers", item.id, "coinsPerUsd", event.target.value)} + /> + + )} + /> + + addRow("whitelist")} + onRemove={(id) => removeRow("whitelist", id)} + renderRow={(item) => ( + <> + updateRow("whitelist", item.id, "userId", event.target.value)} + /> + updateRow("whitelist", item.id, "coinsPerUsd", event.target.value)} + /> + + )} + /> + +
+ + + + +
+ ); +} + +function RateSection({ actionLabel, columns, disabled, onAdd, onRemove, renderRow, rows, title }) { + return ( +
+
+

{title}

+ +
+
+
+ {columns.map((column) => ( + {column} + ))} + 操作 +
+ {rows.length ? ( + rows.map((item) => ( +
+ {renderRow(item)} + +
+ )) + ) : ( +
当前无数据
+ )} +
+
+ ); +} + +function withRowID(item, rowSequence) { + rowSequence.current += 1; + return { ...item, id: `rate-row-${rowSequence.current}` }; +} diff --git a/finance/src/components/FinanceCoinSellerExchangeRateDialog.test.jsx b/finance/src/components/FinanceCoinSellerExchangeRateDialog.test.jsx new file mode 100644 index 0000000..2c249db --- /dev/null +++ b/finance/src/components/FinanceCoinSellerExchangeRateDialog.test.jsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx"; + +test("edits app amount tiers and user whitelist rates", async () => { + const onClose = vi.fn(); + const onLoad = vi.fn(async () => ({ + appCode: "lalu", + tiers: [{ coinsPerUsd: 80000, maxUsdAmount: "200.00", minUsdAmount: "100.00" }], + whitelist: [], + })); + const onSave = vi.fn(async () => ({})); + + render( + , + ); + + await waitFor(() => expect(screen.getByRole("spinbutton", { name: "区间金币汇率" })).toHaveValue(80000)); + fireEvent.click(screen.getByRole("button", { name: "添加白名单" })); + fireEvent.change(screen.getByRole("textbox", { name: "白名单用户 ID" }), { target: { value: "123456" } }); + fireEvent.change(screen.getByRole("spinbutton", { name: "白名单金币汇率" }), { + target: { value: "100000" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + await waitFor(() => + expect(onSave).toHaveBeenCalledWith("lalu", { + tiers: [{ coinsPerUsd: 80000, maxUsdAmount: 200, minUsdAmount: 100 }], + whitelist: [{ coinsPerUsd: 100000, userId: "123456" }], + }), + ); + expect(onClose).toHaveBeenCalled(); +}); diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx index dd85083..845a95e 100644 --- a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx +++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx @@ -2,6 +2,7 @@ import AddOutlined from "@mui/icons-material/AddOutlined"; import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined"; import PaidOutlined from "@mui/icons-material/PaidOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import Alert from "@mui/material/Alert"; import Autocomplete from "@mui/material/Autocomplete"; import Button from "@mui/material/Button"; @@ -12,7 +13,7 @@ import DialogTitle from "@mui/material/DialogTitle"; import InputAdornment from "@mui/material/InputAdornment"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { COIN_SELLER_RECHARGE_CHAINS, COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES, @@ -23,6 +24,7 @@ import { import { buildCoinSellerRechargeReceiptPayload, buildCoinSellerRechargeOrderPayload, + buildCoinSellerRechargeQuotePayload, coinSellerRechargeChainLabel, coinSellerRechargeGrantStatusLabel, coinSellerRechargeProviderLabel, @@ -36,20 +38,25 @@ import { isCoinSellerRechargeVerified, validateCoinSellerRechargeOrderPayload, } from "../format.js"; +import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx"; export function FinanceCoinSellerRechargeOrderList({ actionLoading, apps, canCreate, + canConfigureExchangeRate, canGrant, canVerify, error, filters, loading, onCreate, + onLoadExchangeRate, onFiltersChange, onGrant, onReload, + onQuote, + onSaveExchangeRate, onVerify, onVerifyReceipt, orders, @@ -57,6 +64,10 @@ export function FinanceCoinSellerRechargeOrderList({ const [createOpen, setCreateOpen] = useState(false); const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM); const [formError, setFormError] = useState(""); + const [quote, setQuote] = useState(null); + const [quoteError, setQuoteError] = useState(""); + const [quoteLoading, setQuoteLoading] = useState(false); + const [rateConfigOpen, setRateConfigOpen] = useState(false); const [receiptVerification, setReceiptVerification] = useState(null); const [receiptLoading, setReceiptLoading] = useState(false); const items = orders.items || []; @@ -64,6 +75,57 @@ export function FinanceCoinSellerRechargeOrderList({ const pageSize = Number(orders.pageSize || 50); const total = Number(orders.total || 0); const hasNextPage = page * pageSize < total; + const quoteAppCode = form.appCode; + const quoteTargetUserID = form.targetUserId; + const quoteUSDAmount = form.usdAmount; + const isMakeup = form.isMakeup; + + useEffect(() => { + const payload = buildCoinSellerRechargeQuotePayload({ + appCode: quoteAppCode, + targetUserId: quoteTargetUserID, + usdAmount: quoteUSDAmount, + }); + if (isMakeup) { + setQuote(null); + setQuoteError(""); + setQuoteLoading(false); + setForm((current) => (current.coinAmount === "0" ? current : { ...current, coinAmount: "0" })); + return undefined; + } + if (!createOpen || !payload.appCode || !payload.targetUserId || !Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) { + setQuote(null); + setQuoteError(""); + setQuoteLoading(false); + return undefined; + } + let active = true; + const timer = window.setTimeout(async () => { + setQuoteLoading(true); + setQuoteError(""); + try { + const result = await onQuote(payload); + if (active) { + setQuote(result); + setForm((current) => ({ ...current, coinAmount: String(result?.coinAmount ?? "") })); + } + } catch (quoteRequestError) { + if (active) { + setQuote(null); + setForm((current) => ({ ...current, coinAmount: "" })); + setQuoteError(quoteRequestError.message || "未能生成充值金币"); + } + } finally { + if (active) { + setQuoteLoading(false); + } + } + }, 300); + return () => { + active = false; + window.clearTimeout(timer); + }; + }, [createOpen, isMakeup, onQuote, quoteAppCode, quoteTargetUserID, quoteUSDAmount]); const openCreate = () => { setForm({ @@ -71,11 +133,28 @@ export function FinanceCoinSellerRechargeOrderList({ appCode: filters.appCode || apps[0]?.appCode || "", }); setFormError(""); + setQuote(null); + setQuoteError(""); setReceiptVerification(null); setCreateOpen(true); }; const updateForm = (key, value) => { + if (key === "isMakeup") { + setQuote(null); + setQuoteError(""); + setForm((current) => ({ ...current, coinAmount: value ? "0" : "", isMakeup: value })); + setFormError(""); + return; + } + if (["appCode", "targetUserId", "usdAmount"].includes(key)) { + setQuote(null); + setQuoteError(""); + setForm((current) => ({ ...current, [key]: value, coinAmount: current.isMakeup ? "0" : "" })); + setFormError(""); + setReceiptVerification(null); + return; + } if (key === "providerCode") { setForm((current) => ({ ...current, @@ -100,6 +179,10 @@ export function FinanceCoinSellerRechargeOrderList({ setFormError("请先校验订单号"); return; } + if (!form.isMakeup && !quote?.coinAmount) { + setFormError(quoteError || "请等待系统生成充值金币"); + return; + } const payload = buildCoinSellerRechargeOrderPayload(form); const validationMessage = validateCoinSellerRechargeOrderPayload(payload); if (validationMessage) { @@ -117,8 +200,6 @@ export function FinanceCoinSellerRechargeOrderList({ const payload = buildCoinSellerRechargeReceiptPayload(form); const validationMessage = validateCoinSellerRechargeOrderPayload({ ...buildCoinSellerRechargeOrderPayload(form), - coinAmount: 1, - targetUserId: "verification-only", }); if (validationMessage) { setFormError(validationMessage); @@ -205,6 +286,11 @@ export function FinanceCoinSellerRechargeOrderList({ + {canConfigureExchangeRate ? ( + + ) : null} + 金币 }} - inputProps={{ min: 0, step: "1" }} + helperText={quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)} label="充值金币" required type="number" value={form.coinAmount} - onChange={(event) => onChange("coinAmount", event.target.value)} /> { test("coin seller recharge order create dialog verifies receipt before create", async () => { const onCreate = vi.fn(async () => ({ id: "order-2" })); + const onQuote = vi.fn(async () => ({ + coinAmount: 1000000, + coinsPerUsd: 100000, + matchedUserId: "10001", + rateSource: "whitelist", + })); const onVerifyReceipt = vi.fn(async () => ({ currencyCode: "PHP", providerAmountMinor: 4800, @@ -16,7 +22,12 @@ test("coin seller recharge order create dialog verifies receipt before create", verified: true, })); render( - , + , ); fireEvent.click(screen.getByRole("button", { name: "创建币商充值" })); @@ -30,7 +41,9 @@ test("coin seller recharge order create dialog verifies receipt before create", fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } }); fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } }); - fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } }); + await waitFor(() => expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(1000000)); + expect(screen.getByRole("spinbutton", { name: "充值金币" })).toBeDisabled(); + expect(screen.getByText(/白名单 10001.*1 USD = 100,000 金币/)).toBeInTheDocument(); fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" })); fireEvent.click(await screen.findByRole("option", { name: "PHP" })); fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } }); @@ -53,8 +66,8 @@ test("coin seller recharge order create dialog verifies receipt before create", await waitFor(() => expect(onCreate).toHaveBeenCalledWith({ appCode: "lalu", - coinAmount: 0, externalOrderNo: "V5-001", + isMakeup: false, providerAmountMinor: 4800, providerCode: "v5pay", providerCurrencyCode: "PHP", @@ -65,6 +78,50 @@ test("coin seller recharge order create dialog verifies receipt before create", ); }); +test("makeup order keeps zero coins while creating a normal recharge record", async () => { + const onCreate = vi.fn(async () => ({ id: "makeup-order" })); + const onQuote = vi.fn(); + const onVerifyReceipt = vi.fn(async () => ({ status: "paid", verified: true })); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "创建币商充值" })); + fireEvent.click(screen.getByRole("button", { name: "补单" })); + expect(screen.getByRole("button", { name: "补单" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(0); + expect(screen.getByText("补单不发放金币,充值金额仍正常计入统计")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "123456" } }); + fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "150" } }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" })); + fireEvent.click(await screen.findByRole("option", { name: "PHP" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "7200" } }); + fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "MIFA-MAKEUP-001" } }); + fireEvent.click(screen.getByRole("button", { name: "校验" })); + await screen.findByText(/校验通过/); + fireEvent.click(screen.getByRole("button", { name: "创建" })); + + await waitFor(() => + expect(onCreate).toHaveBeenCalledWith({ + appCode: "lalu", + externalOrderNo: "MIFA-MAKEUP-001", + isMakeup: true, + providerAmountMinor: 720000, + providerCode: "mifapay", + providerCurrencyCode: "PHP", + targetUserId: "123456", + usdAmount: 150, + }), + ); + expect(onQuote).not.toHaveBeenCalled(); +}); + test("coin seller recharge order actions follow verify and grant status", () => { const onVerify = vi.fn(); const onGrant = vi.fn(); @@ -135,6 +192,7 @@ function baseProps(overrides = {}) { actionLoading: "", apps: [{ appCode: "lalu", appName: "lalu" }], canCreate: true, + canConfigureExchangeRate: false, canGrant: true, canVerify: true, error: "", @@ -149,9 +207,12 @@ function baseProps(overrides = {}) { }, loading: false, onCreate: vi.fn(), + onLoadExchangeRate: vi.fn(async () => ({ appCode: "lalu", tiers: [], whitelist: [] })), onFiltersChange: vi.fn(), onGrant: vi.fn(), onReload: vi.fn(), + onQuote: vi.fn(async () => ({ coinAmount: 800000, coinsPerUsd: 80000, rateSource: "tier" })), + onSaveExchangeRate: vi.fn(), onVerify: vi.fn(), onVerifyReceipt: vi.fn(), orders: { diff --git a/finance/src/constants.js b/finance/src/constants.js index 0dd9abd..a51735b 100644 --- a/finance/src/constants.js +++ b/finance/src/constants.js @@ -1,6 +1,7 @@ export const FINANCE_PERMISSIONS = { auditApplication: "finance-application:audit", auditWithdrawalApplications: "finance-withdrawal:audit", + configureCoinSellerRechargeExchangeRate: "coin-seller:exchange-rate", createApplication: "finance-application:create", createCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:create", grantCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:grant", @@ -111,6 +112,7 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = { chain: "", coinAmount: "", externalOrderNo: "", + isMakeup: false, providerAmount: "", providerCode: "mifapay", providerCurrencyCode: "", diff --git a/finance/src/format.js b/finance/src/format.js index f4e4922..2938103 100644 --- a/finance/src/format.js +++ b/finance/src/format.js @@ -134,8 +134,8 @@ export function buildCoinSellerRechargeOrderPayload(form) { const payload = { appCode: stringValue(form.appCode), chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "", - coinAmount: numberValue(form.coinAmount), externalOrderNo: stringValue(form.externalOrderNo), + isMakeup: Boolean(form.isMakeup), providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "", providerCode, providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "", @@ -174,9 +174,6 @@ export function validateCoinSellerRechargeOrderPayload(payload) { if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) { return "请填写大于 0 的 USD 金额"; } - if (!Number.isInteger(payload.coinAmount) || payload.coinAmount < 0) { - return "请填写不小于 0 的整数金币"; - } if (!payload.providerCode) { return "请选择支付渠道"; } @@ -200,6 +197,62 @@ export function validateCoinSellerRechargeOrderPayload(payload) { return ""; } +export function buildCoinSellerRechargeQuotePayload(form) { + return { + appCode: stringValue(form.appCode), + targetUserId: stringValue(form.targetUserId), + usdAmount: numberValue(form.usdAmount), + }; +} + +export function buildCoinSellerRechargeExchangeRatePayload(config) { + return { + tiers: (config.tiers || []).map((item) => ({ + coinsPerUsd: numberValue(item.coinsPerUsd), + maxUsdAmount: numberValue(item.maxUsdAmount), + minUsdAmount: numberValue(item.minUsdAmount), + })), + whitelist: (config.whitelist || []).map((item) => ({ + coinsPerUsd: numberValue(item.coinsPerUsd), + userId: stringValue(item.userId), + })), + }; +} + +export function validateCoinSellerRechargeExchangeRatePayload(payload) { + if (!payload.tiers.length) { + return "至少添加一个 USD 金额区间"; + } + const tiers = [...payload.tiers].sort((left, right) => left.minUsdAmount - right.minUsdAmount); + for (let index = 0; index < tiers.length; index += 1) { + const item = tiers[index]; + if ( + !Number.isFinite(item.minUsdAmount) || + item.minUsdAmount <= 0 || + !Number.isFinite(item.maxUsdAmount) || + item.maxUsdAmount < item.minUsdAmount || + !Number.isInteger(item.coinsPerUsd) || + item.coinsPerUsd <= 0 + ) { + return "请填写正确的 USD 金额区间和金币汇率"; + } + if (index > 0 && item.minUsdAmount <= tiers[index - 1].maxUsdAmount) { + return "USD 金额区间不能重叠"; + } + } + const userIds = new Set(); + for (const item of payload.whitelist) { + if (!item.userId || !Number.isInteger(item.coinsPerUsd) || item.coinsPerUsd <= 0) { + return "请填写正确的白名单用户 ID 和金币汇率"; + } + if (userIds.has(item.userId)) { + return `白名单用户 ID 重复:${item.userId}`; + } + userIds.add(item.userId); + } + return ""; +} + export function normalizeCoinSellerRechargeOrder(item = {}) { return { appCode: stringValue(item.appCode ?? item.app_code), diff --git a/finance/src/format.test.js b/finance/src/format.test.js index 7c7aee4..6c5ca44 100644 --- a/finance/src/format.test.js +++ b/finance/src/format.test.js @@ -2,7 +2,9 @@ import { expect, test } from "vitest"; import { buildApplicationPayload, buildCoinSellerRechargeReceiptPayload, + buildCoinSellerRechargeExchangeRatePayload, buildCoinSellerRechargeOrderPayload, + buildCoinSellerRechargeQuotePayload, coinSellerRechargeProviderLabel, normalizeApplicationPage, normalizeCoinSellerRechargeOrderPage, @@ -13,6 +15,7 @@ import { statusLabel, validateApplicationPayload, validateCoinSellerRechargeOrderPayload, + validateCoinSellerRechargeExchangeRatePayload, walletIdentityLabel, } from "./format.js"; @@ -180,8 +183,8 @@ test("builds validates and normalizes coin seller recharge orders", () => { expect(payload).toEqual({ appCode: "lalu", - coinAmount: 100000, externalOrderNo: "MIFA-001", + isMakeup: false, providerAmountMinor: 125050, providerCode: "mifapay", providerCurrencyCode: "PHP", @@ -208,8 +211,6 @@ test("builds validates and normalizes coin seller recharge orders", () => { expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe( "请填写大于 0 的币种金额", ); - expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe(""); - expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币"); expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字"); expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe( "请选择 USDT 链", @@ -260,8 +261,8 @@ test("builds validates and normalizes coin seller recharge orders", () => { ).toEqual({ appCode: "lalu", chain: "TRON", - coinAmount: 100000, externalOrderNo: "MIFA-001", + isMakeup: false, providerCode: "usdt", remark: "人工补单", targetUserId: "10001", @@ -321,6 +322,38 @@ test("builds validates and normalizes coin seller recharge orders", () => { expect(coinSellerRechargeProviderLabel("mifapay")).toBe("MiFaPay"); }); +test("builds coin seller recharge quotes and validates exchange rate config", () => { + expect(buildCoinSellerRechargeQuotePayload({ appCode: " lalu ", targetUserId: " 123456 ", usdAmount: "150" })).toEqual({ + appCode: "lalu", + targetUserId: "123456", + usdAmount: 150, + }); + const payload = buildCoinSellerRechargeExchangeRatePayload({ + tiers: [ + { minUsdAmount: "100", maxUsdAmount: "200", coinsPerUsd: "80000" }, + { minUsdAmount: "201", maxUsdAmount: "300", coinsPerUsd: "90000" }, + ], + whitelist: [{ userId: " 123456 ", coinsPerUsd: "100000" }], + }); + expect(validateCoinSellerRechargeExchangeRatePayload(payload)).toBe(""); + expect(payload).toEqual({ + tiers: [ + { minUsdAmount: 100, maxUsdAmount: 200, coinsPerUsd: 80000 }, + { minUsdAmount: 201, maxUsdAmount: 300, coinsPerUsd: 90000 }, + ], + whitelist: [{ userId: "123456", coinsPerUsd: 100000 }], + }); + expect( + validateCoinSellerRechargeExchangeRatePayload({ + ...payload, + tiers: [ + payload.tiers[0], + { minUsdAmount: 200, maxUsdAmount: 300, coinsPerUsd: 90000 }, + ], + }), + ).toBe("USD 金额区间不能重叠"); +}); + test("normalizes app recharge detail list rows with optional payment fields", () => { const page = normalizeRechargeBillPage({ items: [ diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index 1a3cff3..52a994b 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -564,6 +564,67 @@ a { grid-template-columns: minmax(0, 1fr); } +.finance-rate-config { + display: grid; + gap: 24px; + padding-top: 8px; +} + +.finance-rate-config__section { + display: grid; + gap: 10px; +} + +.finance-rate-config__heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.finance-rate-config__heading h3 { + font-size: 15px; + margin: 0; +} + +.finance-rate-config__table { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.finance-rate-config__row { + align-items: center; + border-bottom: 1px solid var(--border); + display: grid; + gap: 12px; + padding: 10px 12px; +} + +.finance-rate-config__row:last-child { + border-bottom: 0; +} + +.finance-rate-config__row--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)) 72px; +} + +.finance-rate-config__row--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)) 72px; +} + +.finance-rate-config__row--head { + background: var(--bg-card-strong); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} + +.finance-rate-config__empty { + color: var(--text-secondary); + padding: 24px; + text-align: center; +} + .finance-receipt-field { display: grid; min-width: 0; diff --git a/src/App.test.jsx b/src/App.test.jsx index 041e401..75c51cb 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -181,6 +181,40 @@ test("renders cp weekly rank route with an authenticated session", async () => { expect(await screen.findByText("已发放")).toBeInTheDocument(); }); +test("renders the shareable app user detail route with the detail header", async () => { + setAccessToken("test-token"); + vi.mocked(fetch).mockImplementation(async (input) => { + const url = String(input); + if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) { + return jsonResponse({ + accessToken: "test-token", + permissions: ["app-user:view"], + user: { userId: 1, username: "admin" }, + }); + } + if (url.includes("/v1/admin/apps")) { + return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 }); + } + if (url.includes("/v1/admin/navigation/menus")) { + return jsonResponse([]); + } + if (url.includes("/v1/app/users/10001")) { + return jsonResponse({ + display_user_id: "163337", + status: "active", + user_id: "10001", + username: "detail-user", + }); + } + return jsonResponse(null); + }); + + renderWithRoute("/app/users/10001"); + + expect(await screen.findByRole("heading", { level: 1, name: "用户详情" })).toBeInTheDocument(); + expect((await screen.findAllByText("detail-user")).length).toBeGreaterThan(0); +}); + test("admin routes declare menu code and permission", () => { expect(adminRoutes.length).toBeGreaterThan(0); expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true); diff --git a/src/app/App.jsx b/src/app/App.jsx index 32dc0e4..fd8ddc4 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -3,31 +3,34 @@ import { AdminLayout } from "@/app/layout/AdminLayout.jsx"; import { DeferredPage } from "@/app/router/DeferredPage.jsx"; import { RequireAuth, RequirePermission } from "@/app/router/guards.jsx"; import { adminRoutes, defaultAdminPath, publicRoutes } from "@/app/router/routeConfig"; +import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; function App() { return ( - - {publicRoutes.map((route) => ( - } /> - ))} - }> - }> - } /> - {adminRoutes.map((route) => ( - - - - } - /> - ))} + + + {publicRoutes.map((route) => ( + } /> + ))} + }> + }> + } /> + {adminRoutes.map((route) => ( + + + + } + /> + ))} + - - } /> - + } /> + + ); } diff --git a/src/app/layout/AdminLayout.jsx b/src/app/layout/AdminLayout.jsx index 911a148..7c5a3e5 100644 --- a/src/app/layout/AdminLayout.jsx +++ b/src/app/layout/AdminLayout.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { matchPath, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { getMenus } from "@/features/menus/api"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; @@ -12,6 +12,7 @@ import { mergeNavigationItems } from "@/app/navigation/menu.js"; import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js"; +import { adminRoutes } from "@/app/router/routeConfig"; import { Header } from "./Header.jsx"; import { Sidebar } from "./Sidebar.jsx"; @@ -35,6 +36,10 @@ export function AdminLayout() { return mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus, fallbackNavigation); }, [backendMenus, can]); const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]); + const activeRoute = useMemo( + () => adminRoutes.find((route) => matchPath({ end: true, path: route.path }, location.pathname)), + [location.pathname] + ); useEffect(() => { recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user }); @@ -43,7 +48,7 @@ export function AdminLayout() { return (
- {children} + {children} diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 89d48f9..f69aefc 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -244,6 +244,56 @@ color: var(--primary); } +.genderAgeFemale, +.genderAgeMale, +.genderAgeUnknown { + display: inline-flex; + min-height: 20px; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + color: #fff; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.genderAgeFemale { + background: var(--danger); +} + +.genderAgeMale { + background: var(--primary); +} + +.genderAgeUnknown { + background: var(--text-tertiary); +} + +.userLevels { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} + +.userLevel { + display: inline-flex; + align-items: baseline; + gap: 3px; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 650; +} + +.userLevel strong { + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 750; +} + .coinButton { display: inline-flex; max-width: 100%; @@ -435,17 +485,87 @@ line-height: 1.3; } +.detailDrawerBody { + display: block; + padding-right: 0; +} + +.detailView { + display: flex; + min-width: 0; + min-height: 100%; + flex-direction: column; + container-type: inline-size; +} + +.detailStickyHeader { + position: sticky; + z-index: 2; + top: 0; + flex: 0 0 auto; + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} + .detailHero { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-4); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); +} + +.detailHeroActions { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-2); +} + +.detailMetrics { + display: grid; + min-width: 620px; + grid-template-columns: repeat(7, minmax(72px, 1fr)); + border-top: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); +} + +.detailMetric { + display: grid; + min-width: 0; + gap: 2px; + padding: var(--space-2) var(--space-3); + border-left: 1px solid var(--border-soft); +} + +.detailMetric:first-child { + border-left: 0; +} + +.detailMetric span { + overflow: hidden; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailMetric strong { + overflow: hidden; + color: var(--text-primary); + font-size: var(--admin-font-size); + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; } .detailLoading { + margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--primary-border); border-radius: var(--radius-sm); @@ -455,13 +575,49 @@ font-weight: 700; } +.detailTabs { + min-height: 40px; + margin-top: var(--space-1); +} + +.detailTabs :global(.MuiTabs-indicator) { + height: 3px; + border-radius: var(--radius-pill) var(--radius-pill) 0 0; + background: var(--primary); +} + +.detailTab.detailTab { + min-width: 0; + min-height: 40px; + padding: 0 var(--space-4); + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 750; + letter-spacing: 0; + text-transform: none; +} + +.detailTab.detailTab:hover, +.detailTab.detailTab:global(.Mui-selected) { + color: var(--primary); +} + +.detailTabPanel { + display: grid; + gap: 0; + flex: 1 1 auto; + align-content: start; +} + .detailSection { display: grid; gap: var(--space-3); - padding: var(--space-4); - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card-strong); + padding: var(--space-5) 0; + border-bottom: 1px solid var(--border-soft); +} + +.detailSection:last-child { + border-bottom: 0; } .detailSectionTitle { @@ -474,18 +630,29 @@ .detailGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); + column-gap: var(--space-6); } .detailItem { display: grid; min-width: 0; - gap: 4px; + min-height: 32px; + grid-template-columns: minmax(76px, 0.48fr) minmax(0, 1fr); + align-items: start; + gap: var(--space-2); + padding: 6px 0; + border-bottom: 1px solid var(--detail-border); +} + +.detailItemWide { + grid-column: 1 / -1; } .detailLabel { color: var(--text-tertiary); font-size: 12px; + font-weight: 650; + line-height: var(--admin-line-height); } .detailValue { @@ -494,6 +661,175 @@ color: var(--text-primary); font-size: var(--admin-font-size); font-weight: 650; + line-height: var(--admin-line-height); +} + +.copyableDetailValue { + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-1); +} + +.copyableDetailValue > span { + min-width: 0; + overflow-wrap: anywhere; +} + +.copyDetailButton { + display: inline-flex; + width: 24px; + height: 24px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 14px; +} + +.copyDetailButton:hover, +.copyDetailButton:focus-visible { + background: var(--primary-surface); + color: var(--primary); +} + +.copyDetailButton:focus-visible { + outline: 2px solid var(--primary-border); + outline-offset: 1px; +} + +.detailPageRoot { + display: flex; + height: calc(100vh - var(--header-height) - var(--space-8) - var(--space-4)); + height: calc(100dvh - var(--header-height) - var(--space-8) - var(--space-4)); + min-height: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); +} + +.detailPageToolbar { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex: 0 0 auto; + padding: var(--space-3) var(--space-5); + border-bottom: 1px solid var(--border); +} + +.detailPageTitleGroup { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-4); +} + +.detailPageTitleGroup h1 { + overflow: hidden; + margin: 0; + color: var(--text-primary); + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailPageContent { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 0 var(--space-6) var(--space-6); +} + +.detailPageContent :global(.data-state) { + min-height: 100%; + border: 0; +} + +.detailViewPage { + max-width: 1440px; + min-height: 100%; + margin: 0 auto; +} + +@container (min-width: 900px) { + .detailGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@container (max-width: 640px) { + .detailMetrics { + min-width: 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .detailMetric:nth-child(5) { + border-left: 0; + } + + .detailGrid { + grid-template-columns: minmax(0, 1fr); + } + + .detailItemWide { + grid-column: auto; + } + + .detailHero { + align-items: flex-start; + } + + .detailHeroActions { + align-items: flex-end; + flex-direction: column; + } +} + +@container (max-width: 400px) { + .detailMetrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .detailMetric:nth-child(odd) { + border-left: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .copyDetailButton { + transition: + background-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard), + transform var(--motion-fast) var(--ease-standard); + } + + .copyDetailButton:active { + transform: scale(0.92); + } +} + +@media (max-width: 720px) { + .detailPageToolbar, + .detailPageTitleGroup { + align-items: flex-start; + flex-wrap: wrap; + } + + .detailPageContent { + padding-right: var(--space-4); + padding-left: var(--space-4); + } } .vipBadge, diff --git a/src/features/app-users/components/AppUserDetailProvider.jsx b/src/features/app-users/components/AppUserDetailProvider.jsx index f55fce7..cfec46c 100644 --- a/src/features/app-users/components/AppUserDetailProvider.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.jsx @@ -1,19 +1,9 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -import { formatMillis } from "@/shared/utils/time.js"; import { getAppUser } from "@/features/app-users/api"; -import { appUserStatusLabels } from "@/features/app-users/constants.js"; import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx"; -import { - BanCountdown, - GenderValue, - LevelValue, - OperatorValue, - RolesValue, - formatUsdMinor, -} from "@/features/app-users/components/AppUserValues.jsx"; +import { AppUserDetailView } from "@/features/app-users/components/AppUserDetailView.jsx"; import styles from "@/features/app-users/app-users.module.css"; export function AppUserDetailProvider({ children }) { @@ -43,6 +33,7 @@ export function AppUserDetailProvider({ children }) { try { const latestUser = await getAppUser(seedUser.userId); + // 只接收最后一次打开请求的结果,避免连续点击多行时较慢的旧请求覆盖当前用户。 if (requestSeqRef.current === requestSeq) { setDetailUser({ ...seedUser, ...latestUser }); } @@ -78,172 +69,25 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) { if (!open || !user) { return null; } - const balances = user.balances || []; - const equippedResources = user.equippedResources || []; - const resources = user.resources || []; return ( - -
- - -
- {loading ?
正在刷新用户详情...
: null} -
-

身份信息

-
- - - } /> - - - } /> - - - - } /> -
-
-
-

等级信息

-
- } /> - } /> - } /> - } /> - -
-
-
-

账号状态

-
- - } /> - - - - - - - - - } - /> - - -
-
-
-

资产余额

- -
-
-

当前佩戴

- -
-
-

资源资产

- -
+ + ); } -function DetailItem({ label, value }) { - const isElement = value && typeof value === "object"; - return ( -
- {label} - - {isElement ? value : value === undefined || value === null || value === "" ? "-" : value} - -
- ); -} - -function PrettyValue({ user }) { - if (!user.prettyId || !user.prettyDisplayUserId) { - return "-"; - } - return ; -} - -function VipDetailValue({ vip }) { - const level = Number(vip?.level || 0); - if (level <= 0 || !vip?.active) { - return "VIP 0"; - } - return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`; -} - -function AssetBalanceList({ balances }) { - if (!balances.length) { - return
当前无数据
; - } - return ( -
- {balances.map((balance) => ( -
-
-
{formatAssetType(balance.assetType)}
-
冻结 {formatNumber(balance.frozenAmount)}
-
-
{formatNumber(balance.availableAmount)}
-
- ))} -
- ); -} - -function ResourceList({ resources }) { - if (!resources.length) { - return
当前无数据
; - } - return ( -
    - {resources.map((resource) => ( -
  • - -
    -
    - {resource.name || resource.resourceCode || `资源 ${resource.resourceId}`} - {resource.equipped ? 佩戴中 : null} -
    -
    - {formatResourceType(resource.resourceType)} · 剩余{" "} - {formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)} -
    -
    到期 {formatExpireTime(resource.expiresAtMs)}
    -
    -
  • - ))} -
- ); -} - -function ResourceThumb({ resource }) { - const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl; - if (!imageUrl) { - return {resource.name?.slice(0, 1) || "资"}; - } - return ; -} - -function AppUserStatus({ status }) { - const tone = status === "active" ? "running" : "danger"; - return ( - - - {appUserStatusLabels[status] || status || "-"} - - ); -} - function normalizeDetailSeed(source) { if (source === undefined || source === null) { return {}; @@ -273,63 +117,6 @@ function normalizeDetailSeed(source) { }; } -function formatCountry(user) { - return user.countryDisplayName || user.countryName || user.country || "-"; -} - -function formatNumber(value) { - const number = Number(value || 0); - return Number.isFinite(number) ? number.toLocaleString() : "-"; -} - -function formatAge(value) { - if (value === undefined || value === null || value === "") { - return "-"; - } - const age = Number(value); - return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-"; -} - -function formatVipExpire(vip) { - if (Number(vip?.level || 0) <= 0) { - return "-"; - } - return formatExpireTime(vip?.expiresAtMs); -} - -function formatAssetType(value) { - const normalized = String(value || "").toUpperCase(); - const labels = { - COIN: "金币", - DIAMOND: "钻石", - }; - return labels[normalized] || normalized || "-"; -} - -function formatResourceType(value) { - const labels = { - avatar_frame: "头像框", - badge: "徽章", - chat_bubble: "聊天气泡", - emoji_pack: "表情包", - floating_screen: "飘屏", - gift: "礼物", - mic_seat_animation: "麦位动画", - mic_seat_icon: "麦位图标", - profile_card: "资料卡", - vehicle: "座驾", - }; - return labels[value] || value || "-"; -} - -function formatExpireTime(value) { - const expiresAtMs = Number(value || 0); - if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) { - return "永久"; - } - return formatMillis(expiresAtMs); -} - function firstNonEmpty(...values) { for (const value of values) { const normalized = String(value ?? "").trim(); diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index b31dd98..f42f720 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { getAppUser } from "@/features/app-users/api"; import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; @@ -47,9 +48,11 @@ test("detail drawer keeps the identity hero and shows the expanded admin project render( - - - + + + + + , ); fireEvent.click(screen.getByRole("button", { name: "打开详情" })); @@ -58,11 +61,20 @@ test("detail drawer keeps the identity hero and shows the expanded admin project expect(screen.getByText("注册设备")).toBeInTheDocument(); expect(screen.getByText("iPhone17,2")).toBeInTheDocument(); expect(screen.getByText("主播、BD")).toBeInTheDocument(); - expect(screen.getByText("累计充值")).toBeInTheDocument(); - expect(screen.getByText(/123\.45/)).toBeInTheDocument(); - expect(screen.getByText("永久")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /完整详情/ })).toHaveAttribute("href", "/app/users/10001"); + expect(screen.getByRole("tab", { name: "总览" })).toHaveAttribute("aria-selected", "true"); expect(screen.getAllByText(/真实 Lv8/).length).toBeGreaterThan(0); expect(screen.getByText("VIP 3 · 黄金会员")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "资产" })); + expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0); + expect(screen.getAllByText(/123\.45/).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("tab", { name: "状态记录" })); + expect(screen.getByText("永久")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("link", { name: /完整详情/ })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); function DetailTrigger() { diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx new file mode 100644 index 0000000..ebdaade --- /dev/null +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -0,0 +1,428 @@ +import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import { useEffect, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import { appUserStatusLabels } from "@/features/app-users/constants.js"; +import { + BanCountdown, + GenderAgeTag, + GenderValue, + LevelValue, + OperatorValue, + RolesValue, + formatUsdMinor, +} from "@/features/app-users/components/AppUserValues.jsx"; +import styles from "@/features/app-users/app-users.module.css"; + +const detailTabs = [ + ["overview", "总览"], + ["assets", "资产"], + ["resources", "资源"], + ["status", "状态记录"], +]; + +export function AppUserDetailView({ + fullDetailsHref = "", + loading = false, + mode = "drawer", + onOpenFullDetails, + user, +}) { + const location = useLocation(); + const [activeTab, setActiveTab] = useState("overview"); + const balances = user.balances || []; + const equippedResources = user.equippedResources || []; + const resources = user.resources || []; + + useEffect(() => { + // 抽屉复用同一个组件切换用户时回到总览,避免新用户沿用上一位用户的深层 Tab 而遗漏核心信息。 + setActiveTab("overview"); + }, [user.userId]); + + return ( +
+
+
+ } + size="large" + user={user} + /> +
+ + {fullDetailsHref ? ( + + ) : null} +
+
+
+ + + + + + + +
+ {loading ?
正在刷新用户详情...
: null} + setActiveTab(value)} + > + {detailTabs.map(([value, label]) => ( + + ))} + +
+ +
+ {activeTab === "overview" ? : null} + {activeTab === "assets" ? : null} + {activeTab === "resources" ? ( + + ) : null} + {activeTab === "status" ? : null} +
+
+ ); +} + +function OverviewPanel({ user }) { + return ( + <> + +
+ } /> + + } + /> + } /> + + + } /> + + + + } /> +
+
+ +
+ } /> + } /> + } /> + } /> + +
+
+ + ); +} + +function AssetsPanel({ balances, user }) { + return ( + <> + +
+ + + +
+
+ + + + + ); +} + +function ResourcesPanel({ equippedResources, resources }) { + return ( + <> + + + + + + + + ); +} + +function StatusPanel({ user }) { + return ( + +
+ + } /> + + + + + + } + wide + /> + + +
+
+ ); +} + +function DetailSection({ children, title }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function DetailItem({ label, value, wide = false }) { + const isElement = value && typeof value === "object"; + return ( +
+ {label} + + {isElement ? value : value === undefined || value === null || value === "" ? "-" : value} + +
+ ); +} + +function SummaryMetric({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +function CopyableValue({ label, value }) { + const [copied, setCopied] = useState(false); + const normalized = String(value ?? "").trim(); + + if (!normalized) { + return "-"; + } + + const copy = async () => { + await copyText(normalized); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + return ( + + {normalized} + + + ); +} + +function PrettyValue({ user }) { + if (!user.prettyId || !user.prettyDisplayUserId) { + return "-"; + } + return ; +} + +function VipDetailValue({ vip }) { + const level = Number(vip?.level || 0); + if (level <= 0 || !vip?.active) { + return "VIP 0"; + } + return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`; +} + +function AssetBalanceList({ balances }) { + if (!balances.length) { + return
当前无数据
; + } + return ( +
+ {balances.map((balance) => ( +
+
+
{formatAssetType(balance.assetType)}
+
冻结 {formatNumber(balance.frozenAmount)}
+
+
{formatNumber(balance.availableAmount)}
+
+ ))} +
+ ); +} + +function ResourceList({ resources }) { + if (!resources.length) { + return
当前无数据
; + } + return ( +
    + {resources.map((resource) => ( +
  • + +
    +
    + {resource.name || resource.resourceCode || `资源 ${resource.resourceId}`} + {resource.equipped ? 佩戴中 : null} +
    +
    + {formatResourceType(resource.resourceType)} · 剩余 {formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)} +
    +
    到期 {formatExpireTime(resource.expiresAtMs)}
    +
    +
  • + ))} +
+ ); +} + +function ResourceThumb({ resource }) { + const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl; + if (!imageUrl) { + return {resource.name?.slice(0, 1) || "资"}; + } + return ; +} + +function AppUserStatus({ status }) { + const tone = status === "active" ? "running" : "danger"; + return ( + + + {appUserStatusLabels[status] || status || "-"} + + ); +} + +function formatSummaryLevel(level) { + const value = Number(level?.displayLevel ?? level?.realLevel); + return Number.isFinite(value) && value >= 0 ? `Lv${Math.trunc(value)}` : "-"; +} + +function formatVipSummary(vip) { + const level = Number(vip?.level || 0); + return level > 0 && vip?.active ? `VIP ${level}` : "VIP 0"; +} + +function formatCompactUsdMinor(value) { + const amount = Number(value) / 100; + if (!Number.isFinite(amount)) { + return "-"; + } + return new Intl.NumberFormat("en-US", { + currency: "USD", + maximumFractionDigits: Math.abs(amount) >= 1000 ? 1 : 2, + notation: Math.abs(amount) >= 1000 ? "compact" : "standard", + style: "currency", + }).format(amount); +} + +function formatCountry(user) { + return user.countryDisplayName || user.countryName || user.country || "-"; +} + +function formatNumber(value) { + const number = Number(value || 0); + return Number.isFinite(number) ? number.toLocaleString() : "-"; +} + +function formatAge(value) { + if (value === undefined || value === null || value === "") { + return "-"; + } + const age = Number(value); + return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-"; +} + +function formatVipExpire(vip) { + if (Number(vip?.level || 0) <= 0) { + return "-"; + } + return formatExpireTime(vip?.expiresAtMs); +} + +function formatAssetType(value) { + const normalized = String(value || "").toUpperCase(); + const labels = { COIN: "金币", DIAMOND: "钻石" }; + return labels[normalized] || normalized || "-"; +} + +function formatResourceType(value) { + const labels = { + avatar_frame: "头像框", + badge: "徽章", + chat_bubble: "聊天气泡", + emoji_pack: "表情包", + floating_screen: "飘屏", + gift: "礼物", + mic_seat_animation: "麦位动画", + mic_seat_icon: "麦位图标", + profile_card: "资料卡", + vehicle: "座驾", + }; + return labels[value] || value || "-"; +} + +function formatExpireTime(value) { + const expiresAtMs = Number(value || 0); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) { + return "永久"; + } + return formatMillis(expiresAtMs); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // 后台可能运行在非安全上下文,Clipboard API 被拒绝时继续使用兼容复制路径。 + } + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} diff --git a/src/features/app-users/components/AppUserValues.jsx b/src/features/app-users/components/AppUserValues.jsx index d18c4cb..74faeb9 100644 --- a/src/features/app-users/components/AppUserValues.jsx +++ b/src/features/app-users/components/AppUserValues.jsx @@ -19,6 +19,44 @@ export function GenderValue({ gender }) { return formatGender(gender); } +export function GenderAgeTag({ age, gender }) { + const normalizedGender = normalizeGender(gender); + const normalizedAge = Number(age); + const ageLabel = Number.isFinite(normalizedAge) && normalizedAge >= 0 ? normalizedAge : "-"; + + // 未设置或无法识别的性别不伪造颜色语义,仍保留年龄,避免列表信息因字段迁移缺失而消失。 + if (!normalizedGender) { + return 年龄 {ageLabel}; + } + + return ( + + {normalizedGender === "female" ? "♀" : "♂"} {ageLabel} + + ); +} + +export function UserLevelsValue({ levels }) { + const entries = [ + ["财富", levels?.wealth], + ["魅力", levels?.charm], + ["游戏", levels?.game], + ]; + return ( +
+ {entries.map(([label, level]) => ( + + {label} + {level ? `Lv${numericLevel(level.displayLevel ?? level.realLevel)}` : "-"} + + ))} +
+ ); +} + export function LevelValue({ level, showReal = false }) { if (!level) { return "-"; @@ -112,6 +150,17 @@ export function formatGender(gender) { return gender || "-"; } +function normalizeGender(gender) { + const value = String(gender || "").toLowerCase(); + if (value === "female" || value === "f" || value === "女") { + return "female"; + } + if (value === "male" || value === "m" || value === "男") { + return "male"; + } + return ""; +} + export function formatUsdMinor(value) { const amount = Number(value); if (!Number.isFinite(amount)) { diff --git a/src/features/app-users/pages/AppUserDetailPage.jsx b/src/features/app-users/pages/AppUserDetailPage.jsx new file mode 100644 index 0000000..e1fd0bf --- /dev/null +++ b/src/features/app-users/pages/AppUserDetailPage.jsx @@ -0,0 +1,61 @@ +import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined"; +import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; +import { useCallback } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { Button } from "@/shared/ui/Button.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { getAppUser } from "@/features/app-users/api"; +import { AppUserDetailView } from "@/features/app-users/components/AppUserDetailView.jsx"; +import styles from "@/features/app-users/app-users.module.css"; + +export function AppUserDetailPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { userId = "" } = useParams(); + const normalizedUserId = String(userId).trim(); + const loadUser = useCallback(() => getAppUser(normalizedUserId), [normalizedUserId]); + const { data, error, loading, reload } = useAdminQuery(loadUser, { + enabled: Boolean(normalizedUserId), + errorMessage: "加载用户详情失败", + initialData: null, + queryKey: ["app-users", "detail", normalizedUserId], + }); + + if (loading && !data) { + return ; + } + + const pageError = normalizedUserId ? error : "缺少用户 ID"; + const returnPath = resolveReturnPath(location.state?.from); + + return ( +
+
+
+ +

用户详情

+
+ +
+
+ + {data ? : null} + +
+
+ ); +} + +function resolveReturnPath(value) { + const path = String(value || ""); + // 只恢复站内绝对路径,避免路由 state 被外部输入污染后形成开放跳转。 + return path.startsWith("/") && !path.startsWith("//") ? path : "/app/users"; +} diff --git a/src/features/app-users/pages/AppUserDetailPage.test.jsx b/src/features/app-users/pages/AppUserDetailPage.test.jsx new file mode 100644 index 0000000..427211e --- /dev/null +++ b/src/features/app-users/pages/AppUserDetailPage.test.jsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { QueryProvider } from "@/shared/query/QueryProvider.jsx"; +import { getAppUser } from "@/features/app-users/api"; +import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx"; + +vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() })); + +test("full user detail route loads the URL user and returns to its source list", async () => { + vi.mocked(getAppUser).mockResolvedValue({ + age: 24, + defaultDisplayUserId: "163337", + gender: "female", + levels: { + charm: { displayLevel: 3, realLevel: 3 }, + game: { displayLevel: 6, realLevel: 6 }, + wealth: { displayLevel: 4, realLevel: 4 }, + }, + status: "active", + userId: "10001", + username: "tester", + vip: { active: false, level: 0 }, + }); + + render( + + + + } /> + } /> + + + , + ); + + expect((await screen.findAllByText("tester")).length).toBeGreaterThan(0); + expect(getAppUser).toHaveBeenCalledWith("10001"); + expect(screen.getByRole("tab", { name: "总览" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /完整详情/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /返回用户列表/ })); + expect(await screen.findByTestId("location-value")).toHaveTextContent("/app/users?status=active"); +}); + +function LocationValue() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index 04d2793..46ce885 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -27,10 +27,10 @@ import { formatMillis } from "@/shared/utils/time.js"; import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js"; import { BanCountdown, - GenderValue, - LevelValue, OperatorValue, RolesValue, + GenderAgeTag, + UserLevelsValue, } from "@/features/app-users/components/AppUserValues.jsx"; import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js"; import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx"; @@ -92,42 +92,24 @@ export function AppUserListPage() { { key: "identity", label: "用户", - width: "minmax(240px, 1.5fr)", + width: "minmax(260px, 1.6fr)", filter: createUserIdentityColumnFilter({ value: page.userFilter, onChange: page.changeUserFilter, }), - render: (user) => , + render: (user) => ( + } + openInAppUserDetail + user={user} + /> + ), }, { - key: "gender", - label: "性别", - width: "minmax(72px, 0.4fr)", - render: (user) => , - }, - { - key: "age", - label: "年龄", - width: "minmax(72px, 0.4fr)", - render: (user) => (user.age === undefined || user.age === null ? "-" : `${user.age} 岁`), - }, - { - key: "wealthLevel", - label: "富豪等级", - width: "minmax(118px, 0.7fr)", - render: (user) => , - }, - { - key: "charmLevel", - label: "魅力等级", - width: "minmax(118px, 0.7fr)", - render: (user) => , - }, - { - key: "gameLevel", - label: "游戏等级", - width: "minmax(96px, 0.55fr)", - render: (user) => , + key: "levels", + label: "财富/魅力/游戏等级", + width: "minmax(230px, 1.15fr)", + render: (user) => , }, { key: "roles", diff --git a/src/features/app-users/pages/AppUserListPage.test.jsx b/src/features/app-users/pages/AppUserListPage.test.jsx index 237b153..d0040f8 100644 --- a/src/features/app-users/pages/AppUserListPage.test.jsx +++ b/src/features/app-users/pages/AppUserListPage.test.jsx @@ -36,7 +36,9 @@ test("clicking an app user's coin opens the coin ledger drawer", () => { expect(screen.getByText("VIP 2")).toBeInTheDocument(); expect(screen.getByText("VIP888")).toBeInTheDocument(); expect(screen.getByText("公会长、BD")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "调整富豪等级列宽" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "调整财富/魅力/游戏等级列宽" })).toBeInTheDocument(); + expect(screen.getByLabelText("女,24 岁")).toBeInTheDocument(); + expect(screen.getByText("财富")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "调整最新成功登录列宽" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" })); diff --git a/src/features/app-users/routes.js b/src/features/app-users/routes.js index aa915f9..0779e20 100644 --- a/src/features/app-users/routes.js +++ b/src/features/app-users/routes.js @@ -9,6 +9,14 @@ export const appUserRoutes = [ path: "/app/users", permission: PERMISSIONS.appUserView, }, + { + label: "用户详情", + loader: () => import("./pages/AppUserDetailPage.jsx").then((module) => module.AppUserDetailPage), + menuCode: MENU_CODES.appUserDetail, + pageKey: "app-user-detail", + path: "/app/users/:userId", + permission: PERMISSIONS.appUserView, + }, { label: "登录日志", loader: () => import("./pages/AppUserLoginLogsPage.jsx").then((module) => module.AppUserLoginLogsPage), diff --git a/src/features/dashboard/api.test.ts b/src/features/dashboard/api.test.ts new file mode 100644 index 0000000..0684feb --- /dev/null +++ b/src/features/dashboard/api.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { setSelectedAppCode } from "@/shared/api/request"; +import { getDashboardUserProfileOverview } from "./api"; + +afterEach(() => { + setSelectedAppCode("lalu"); + vi.unstubAllGlobals(); +}); + +test("requests the app-scoped user profile overview with the current day range and time zone", async () => { + setSelectedAppCode("huwaa"); + const payload = { + activeUsers: 48, + appCode: "huwaa", + appVersionDistribution: [{ count: 30, key: "2.1.0", label: "2.1.0" }], + genderDistribution: [{ count: 32, key: "female", label: "女" }], + newUsers: 7, + totalUsers: 120, + updatedAtMs: 1783728000000 + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ code: 0, data: payload }))) + ); + + const result = await getDashboardUserProfileOverview({ + appCode: "huwaa", + endMs: 1783728000000, + startMs: 1783699200000, + statTz: "Asia/Shanghai" + }); + const [input, init] = vi.mocked(fetch).mock.calls[0]; + const url = new URL(String(input)); + + expect(url.pathname).toBe("/api/v1/dashboard/user-profile-overview"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + app_code: "huwaa", + end_ms: "1783728000000", + start_ms: "1783699200000", + stat_tz: "Asia/Shanghai" + }); + expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" }); + expect(result).toEqual(payload); +}); diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts index c6a28f6..ab26429 100644 --- a/src/features/dashboard/api.ts +++ b/src/features/dashboard/api.ts @@ -1,7 +1,25 @@ import { apiRequest } from "@/shared/api/request"; import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; -import type { DashboardOverviewDto } from "@/shared/api/types"; +import type { + DashboardOverviewDto, + DashboardUserProfileOverviewDto, + DashboardUserProfileOverviewQuery +} from "@/shared/api/types"; export function getDashboardOverview(): Promise { return apiRequest(apiEndpointPath(API_OPERATIONS.dashboardOverview)); } + +export function getDashboardUserProfileOverview( + query: DashboardUserProfileOverviewQuery +): Promise { + // app_code 明确参与统计聚合;请求层仍会附带 X-App-Code,供后端执行应用数据权限校验。 + return apiRequest(apiEndpointPath(API_OPERATIONS.dashboardUserProfileOverview), { + query: { + app_code: query.appCode, + end_ms: query.endMs, + start_ms: query.startMs, + stat_tz: query.statTz + } + }); +} diff --git a/src/features/dashboard/components/DashboardCharts.jsx b/src/features/dashboard/components/DashboardCharts.jsx index ee9fcb5..45ad1f9 100644 --- a/src/features/dashboard/components/DashboardCharts.jsx +++ b/src/features/dashboard/components/DashboardCharts.jsx @@ -1,10 +1,40 @@ -import { ChartCard } from "@/shared/charts/ChartCard.jsx"; +import { Card } from "@/shared/ui/Card.jsx"; +import { DistributionPieChart } from "@/shared/charts/DistributionPieChart.jsx"; + +const GENDER_COLOR_TOKENS = { + female: "--chart-red", + male: "--chart-blue", + unknown: "--stopped" +}; export function DashboardCharts({ overview }) { + const genderDistribution = withGenderColors(overview?.genderDistribution); + const appVersionDistribution = overview?.appVersionDistribution || []; + return ( -
- - +
+ +
+

性别人数分布

+ 自然用户 · 已完成资料 · 排除机器人/快捷账号 +
+ +
+ +
+

App 版本号人数分布

+ 自然用户 · 最近一次成功登录版本 +
+ +
); } + +function withGenderColors(distribution) { + return (Array.isArray(distribution) ? distribution : []).map((item) => ({ + ...item, + // 性别颜色按稳定 key 固定,避免后端排序变化导致同一性别在刷新后换色。 + colorToken: GENDER_COLOR_TOKENS[String(item?.key || "").toLowerCase()] + })); +} diff --git a/src/features/dashboard/components/DashboardStats.jsx b/src/features/dashboard/components/DashboardStats.jsx index 1299703..65cc95d 100644 --- a/src/features/dashboard/components/DashboardStats.jsx +++ b/src/features/dashboard/components/DashboardStats.jsx @@ -1,14 +1,41 @@ -import DeveloperBoardOutlined from "@mui/icons-material/DeveloperBoardOutlined"; -import DnsOutlined from "@mui/icons-material/DnsOutlined"; -import StorageOutlined from "@mui/icons-material/StorageOutlined"; +import OnlinePredictionOutlined from "@mui/icons-material/OnlinePredictionOutlined"; +import PeopleAltOutlined from "@mui/icons-material/PeopleAltOutlined"; +import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; import { KpiCard } from "@/shared/ui/KpiCard.jsx"; -export function DashboardStats({ overview, stats }) { +const countFormatter = new Intl.NumberFormat("zh-CN"); + +export function DashboardStats({ overview }) { return ( -
- 启用 {stats.activeUsers}} /> - - +
+ + +
); } + +function formatCount(value) { + const count = Number(value); + return countFormatter.format(Number.isFinite(count) && count > 0 ? count : 0); +} diff --git a/src/features/dashboard/components/DashboardUserProfile.jsx b/src/features/dashboard/components/DashboardUserProfile.jsx new file mode 100644 index 0000000..bcb4e3b --- /dev/null +++ b/src/features/dashboard/components/DashboardUserProfile.jsx @@ -0,0 +1,73 @@ +import Refresh from "@mui/icons-material/Refresh"; +import Skeleton from "@mui/material/Skeleton"; +import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; +import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx"; +import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx"; +import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js"; +import { Button } from "@/shared/ui/Button.jsx"; +import { usePageLoadingStatus } from "@/shared/ui/PageLoadBoundary.jsx"; + +export function DashboardUserProfile() { + const page = useDashboardPage(); + const { formatMillis } = useTimeZone(); + // 首次请求接入整页加载边界;边界释放后的 App/时区切换则展示本区域自己的骨架,避免页面空白。 + usePageLoadingStatus(page.loading); + + return ( +
+
+

用户画像

+ {page.overview?.updatedAtMs ? 数据更新时间 {formatMillis(page.overview.updatedAtMs)} : null} +
+ {renderContent(page)} +
+ ); +} + +function renderContent(page) { + if (page.loading) { + return ; + } + if (page.error) { + return ( +
+ {page.error} + +
+ ); + } + if (!page.overview) { + return ( +
+ 当前无数据 +
+ ); + } + + return ( + <> + + + + ); +} + +function DashboardUserProfileSkeleton() { + return ( + + ); +} diff --git a/src/features/dashboard/components/DashboardUserProfile.test.jsx b/src/features/dashboard/components/DashboardUserProfile.test.jsx new file mode 100644 index 0000000..8d08e6b --- /dev/null +++ b/src/features/dashboard/components/DashboardUserProfile.test.jsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + page: null, + reload: vi.fn(), + reportLoading: vi.fn() +})); + +vi.mock("@/app/timezone/TimeZoneProvider.jsx", () => ({ + useTimeZone: () => ({ formatMillis: () => "2026-07-11 10:30:00" }) +})); + +vi.mock("@/features/dashboard/hooks/useDashboardPage.js", () => ({ + useDashboardPage: () => mocks.page +})); + +vi.mock("@/shared/ui/PageLoadBoundary.jsx", () => ({ + usePageLoadingStatus: mocks.reportLoading +})); + +vi.mock("@/shared/charts/DistributionPieChart.jsx", () => ({ + DistributionPieChart: ({ ariaLabel, data }) => ( +
{data.map((item) => `${item.label}:${item.count}`).join("|")}
+ ) +})); + +import { DashboardUserProfile } from "./DashboardUserProfile.jsx"; + +beforeEach(() => { + mocks.reload.mockReset(); + mocks.reportLoading.mockReset(); + mocks.page = { + error: "", + loading: false, + overview: { + activeUsers: 456, + appCode: "lalu", + appVersionDistribution: [ + { count: 300, key: "2.1.0", label: "2.1.0" }, + { count: 156, key: "2.0.9", label: "2.0.9" } + ], + genderDistribution: [ + { count: 700, key: "female", label: "女" }, + { count: 534, key: "male", label: "男" } + ], + newUsers: 32, + totalUsers: 1234, + updatedAtMs: 1783737000000 + }, + reload: mocks.reload + }; +}); + +test("renders the three user KPIs and both distribution charts with explicit metric scope", () => { + render(); + + expect(screen.getByText("总人数")).toBeInTheDocument(); + expect(screen.getByText("1,234")).toBeInTheDocument(); + expect(screen.getByText("当天新增")).toBeInTheDocument(); + expect(screen.getByText("32")).toBeInTheDocument(); + expect(screen.getByText("当前日活")).toBeInTheDocument(); + expect(screen.getByText("456")).toBeInTheDocument(); + expect(screen.getByText("性别人数分布")).toBeInTheDocument(); + expect(screen.getByLabelText("自然用户性别人数分布饼图")).toHaveTextContent("女:700|男:534"); + expect(screen.getByText("App 版本号人数分布")).toBeInTheDocument(); + expect(screen.getByText("自然用户 · 最近一次成功登录版本")).toBeInTheDocument(); + expect(screen.getByLabelText("自然用户最近一次成功登录 App 版本号人数分布饼图")).toHaveTextContent("2.1.0:300|2.0.9:156"); + expect(screen.getByText("数据更新时间 2026-07-11 10:30:00")).toBeInTheDocument(); + expect(mocks.reportLoading).toHaveBeenCalledWith(false); +}); + +test("shows a retryable error without removing the user profile section", () => { + mocks.page = { error: "画像接口暂不可用", loading: false, overview: null, reload: mocks.reload }; + + render(); + fireEvent.click(screen.getByRole("button", { name: "重试" })); + + expect(screen.getByRole("alert")).toHaveTextContent("画像接口暂不可用"); + expect(mocks.reload).toHaveBeenCalledTimes(1); +}); + +test("renders animated placeholders while loading and a stable empty state for an empty response", () => { + mocks.page = { error: "", loading: true, overview: null, reload: mocks.reload }; + const { container, rerender } = render(); + + expect(container.querySelectorAll(".dashboard-profile-skeleton__kpi")).toHaveLength(3); + expect(container.querySelectorAll(".dashboard-profile-skeleton__chart")).toHaveLength(2); + expect(mocks.reportLoading).toHaveBeenLastCalledWith(true); + + mocks.page = { error: "", loading: false, overview: null, reload: mocks.reload }; + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("当前无数据"); +}); diff --git a/src/features/dashboard/dashboard.css b/src/features/dashboard/dashboard.css index 60e9c16..da0a13f 100644 --- a/src/features/dashboard/dashboard.css +++ b/src/features/dashboard/dashboard.css @@ -1,10 +1,156 @@ .overview-charts { display: grid; - grid-template-columns: repeat(3, minmax(260px, 1fr)); + grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); } +.dashboard-profile-section { + margin-top: var(--space-8); + padding-bottom: var(--space-4); +} + +.dashboard-profile-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +.dashboard-profile-head h2 { + margin: 0; + color: var(--text-primary); + font-size: calc(var(--admin-font-size) + 2px); + font-weight: 750; +} + +.dashboard-profile-head > span, +.dashboard-distribution-card__head > span { + color: var(--text-tertiary); + font-size: var(--admin-font-size); +} + +.dashboard-profile-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-profile-kpis .kpi-value { + font-size: calc(var(--admin-font-size) * 1.7); +} + +.dashboard-profile-state { + display: flex; + min-height: 220px; + align-items: center; + justify-content: center; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + color: var(--text-tertiary); +} + +.dashboard-profile-skeleton { + display: grid; + gap: var(--space-4); +} + +.dashboard-profile-skeleton__kpis, +.dashboard-profile-skeleton__charts { + display: grid; + gap: var(--space-4); +} + +.dashboard-profile-skeleton__kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dashboard-profile-skeleton__charts { + grid-template-columns: repeat(2, minmax(280px, 1fr)); +} + +.dashboard-profile-skeleton__kpi { + height: 132px !important; +} + +.dashboard-profile-skeleton__chart { + height: 304px !important; +} + +.dashboard-distribution-card__head { + align-items: flex-start; + flex-wrap: wrap; +} + +.dashboard-distribution-card__head .card-title { + min-width: 0; + flex: 1 1 180px; + margin: 0; +} + +.dashboard-distribution-card__head > span { + flex: 0 1 auto; + text-align: right; +} + +.distribution-pie { + display: grid; + min-height: 216px; + grid-template-columns: minmax(160px, 0.82fr) minmax(180px, 1fr); + align-items: center; + gap: var(--space-5); +} + +.distribution-pie__chart { + width: 100%; + height: 216px; + opacity: 0; + animation: panel-enter 420ms var(--ease-emphasized) 120ms both; +} + +.distribution-pie__legend { + display: grid; + max-height: 216px; + overflow-y: auto; + gap: var(--space-3); + padding-right: var(--space-1); +} + +.distribution-pie__legend-row { + display: grid; + min-width: 0; + grid-template-columns: 8px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + color: var(--text-secondary); + font-size: var(--admin-font-size); +} + +.distribution-pie__legend-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-pill); +} + +.distribution-pie__legend-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.distribution-pie__legend-row strong { + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.distribution-pie-empty { + display: grid; + min-height: 216px; + place-items: center; + color: var(--text-tertiary); +} + .workspace-entry-section { margin-top: var(--space-4); } @@ -450,3 +596,36 @@ margin-top: var(--space-4); padding-bottom: var(--space-2); } + +@media (max-width: 1080px) { + .dashboard-profile-skeleton__kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + .distribution-pie { + grid-template-columns: 1fr; + gap: var(--space-3); + } + + .distribution-pie__chart { + height: 180px; + } + + .distribution-pie__legend { + max-height: 120px; + } +} + +@media (max-width: 760px) { + .dashboard-profile-head { + align-items: flex-start; + flex-direction: column; + } + + .dashboard-profile-skeleton__kpis, + .dashboard-profile-skeleton__charts { + grid-template-columns: 1fr; + } +} diff --git a/src/features/dashboard/hooks/useDashboardPage.js b/src/features/dashboard/hooks/useDashboardPage.js index 8bd6fcf..33586ac 100644 --- a/src/features/dashboard/hooks/useDashboardPage.js +++ b/src/features/dashboard/hooks/useDashboardPage.js @@ -1,48 +1,48 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback } from "react"; +import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; +import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; +import { getDashboardUserProfileOverview } from "@/features/dashboard/api"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; -import { getDashboardOverview } from "@/features/dashboard/api"; +import { nextDayStartMillis, startOfDayMillis } from "@/shared/utils/time.js"; -const emptyData = { overview: null }; +const DAY_ROLLOVER_DELAY_MS = 1000; export function useDashboardPage() { - const [range, setRange] = useState("近 1 小时"); + const { appCode } = useAppScope(); + const { timeZone } = useTimeZone(); - const queryFn = useCallback(async () => { - const overview = await getDashboardOverview(); - return { overview }; - }, []); + const queryFn = useCallback(() => { + // 每次实际请求时重新取 now,确保跨过零点后的手动刷新不会继续沿用前一天的统计区间。 + const endMs = Date.now(); + const startMs = startOfDayMillis(endMs, timeZone); + if (startMs === null) { + // 无法计算边界时直接失败,避免用错误日期请求并把跨天数据伪装成“当天”。 + throw new Error("无法计算当天统计时间范围"); + } + return getDashboardUserProfileOverview({ appCode, endMs, startMs, statTz: timeZone }); + }, [appCode, timeZone]); const { data, error, loading, reload } = useAdminQuery(queryFn, { - errorMessage: "加载总览失败", - initialData: emptyData, - keepPreviousData: true, - queryKey: ["dashboard", "overview"] + errorMessage: "加载用户画像失败", + initialData: null, + // App 与时区都会改变统计口径,必须隔离缓存,避免切换时短暂展示上一口径的数据。 + queryKey: ["dashboard", "user-profile-overview", appCode, timeZone], + // 页面可能整夜保持打开;在所选显示时区跨过零点后主动重取,确保“当天新增/当前日活”不会停留在前一天。 + refetchInterval: () => dashboardDayRolloverInterval(timeZone) }); - const overview = data?.overview; - - const stats = useMemo(() => { - if (!overview) { - return { activeUsers: 0, disabledUsers: 0, lockedUsers: 0, logsToday: 0, menuTotal: 0, rolesTotal: 0, usersTotal: 0 }; - } - return { - activeUsers: overview.activeUsers, - disabledUsers: overview.disabledUsers, - lockedUsers: overview.lockedUsers, - logsToday: overview.logsToday, - menuTotal: overview.menuTotal, - rolesTotal: overview.rolesTotal, - usersTotal: overview.usersTotal - }; - }, [overview]); - return { error, loading, - overview, - range, - reload, - setRange, - stats + overview: data, + reload }; } + +export function dashboardDayRolloverInterval(timeZone, now = Date.now()) { + const nextStartMs = nextDayStartMillis(now, timeZone); + if (nextStartMs === null) { + return false; + } + return Math.max(DAY_ROLLOVER_DELAY_MS, nextStartMs - now + DAY_ROLLOVER_DELAY_MS); +} diff --git a/src/features/dashboard/hooks/useDashboardPage.test.jsx b/src/features/dashboard/hooks/useDashboardPage.test.jsx new file mode 100644 index 0000000..4d91563 --- /dev/null +++ b/src/features/dashboard/hooks/useDashboardPage.test.jsx @@ -0,0 +1,74 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + appCode: "lalu", + getDashboardUserProfileOverview: vi.fn(), + queryFn: null, + queryOptions: null, + reload: vi.fn(), + timeZone: "Asia/Shanghai" +})); + +vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({ + useAppScope: () => ({ appCode: mocks.appCode }) +})); + +vi.mock("@/app/timezone/TimeZoneProvider.jsx", () => ({ + useTimeZone: () => ({ timeZone: mocks.timeZone }) +})); + +vi.mock("@/features/dashboard/api", () => ({ + getDashboardUserProfileOverview: mocks.getDashboardUserProfileOverview +})); + +vi.mock("@/shared/hooks/useAdminQuery.js", () => ({ + useAdminQuery: (queryFn, options) => { + mocks.queryFn = queryFn; + mocks.queryOptions = options; + return { data: null, error: "", loading: false, reload: mocks.reload }; + } +})); + +import { useDashboardPage } from "./useDashboardPage.js"; + +beforeEach(() => { + mocks.appCode = "lalu"; + mocks.timeZone = "Asia/Shanghai"; + mocks.getDashboardUserProfileOverview.mockResolvedValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +test("isolates the query by App and time zone and requests the current local day", async () => { + const now = Date.UTC(2026, 6, 11, 2, 30, 0); + vi.spyOn(Date, "now").mockReturnValue(now); + const { rerender } = renderHook(() => useDashboardPage()); + + expect(mocks.queryOptions.queryKey).toEqual(["dashboard", "user-profile-overview", "lalu", "Asia/Shanghai"]); + expect(mocks.queryOptions.refetchInterval()).toBe(Date.UTC(2026, 6, 11, 16, 0, 1) - now); + await mocks.queryFn(); + expect(mocks.getDashboardUserProfileOverview).toHaveBeenLastCalledWith({ + appCode: "lalu", + endMs: now, + startMs: Date.UTC(2026, 6, 10, 16, 0, 0), + statTz: "Asia/Shanghai" + }); + + mocks.appCode = "huwaa"; + mocks.timeZone = "UTC"; + rerender(); + + expect(mocks.queryOptions.queryKey).toEqual(["dashboard", "user-profile-overview", "huwaa", "UTC"]); + expect(mocks.queryOptions.refetchInterval()).toBe(Date.UTC(2026, 6, 12, 0, 0, 1) - now); + await mocks.queryFn(); + expect(mocks.getDashboardUserProfileOverview).toHaveBeenLastCalledWith({ + appCode: "huwaa", + endMs: now, + startMs: Date.UTC(2026, 6, 11, 0, 0, 0), + statTz: "UTC" + }); +}); diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx index 3a48c55..e2fdc7a 100644 --- a/src/features/dashboard/pages/OverviewPage.jsx +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -1,5 +1,11 @@ import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx"; +import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx"; export function OverviewPage() { - return ; + return ( + <> + + + + ); } diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 1920490..8ae1e0e 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -78,6 +78,7 @@ export const API_OPERATIONS = { createWeeklyStarCycle: "createWeeklyStarCycle", creditCoinSellerStock: "creditCoinSellerStock", dashboardOverview: "dashboardOverview", + dashboardUserProfileOverview: "dashboardUserProfileOverview", debitCoinSellerStock: "debitCoinSellerStock", deleteAchievementDefinition: "deleteAchievementDefinition", deleteAgency: "deleteAgency", @@ -126,6 +127,7 @@ export const API_OPERATIONS = { getCPConfig: "getCPConfig", getCPWeeklyRankConfig: "getCPWeeklyRankConfig", getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig", + getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate", getFinanceScope: "getFinanceScope", getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig", getHumanRoomRobotConfig: "getHumanRoomRobotConfig", @@ -267,6 +269,7 @@ export const API_OPERATIONS = { navigationMenus: "navigationMenus", publishHostAgencyPolicy: "publishHostAgencyPolicy", publishPolicyInstance: "publishPolicyInstance", + quoteFinanceCoinSellerRechargeOrder: "quoteFinanceCoinSellerRechargeOrder", recyclePrettyId: "recyclePrettyId", refresh: "refresh", refreshGoogleRechargePaid: "refreshGoogleRechargePaid", @@ -275,6 +278,7 @@ export const API_OPERATIONS = { rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication", renameCountryCode: "renameCountryCode", replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates", + replaceFinanceCoinSellerRechargeExchangeRate: "replaceFinanceCoinSellerRechargeExchangeRate", replaceRegionBlocks: "replaceRegionBlocks", replaceRegionCountries: "replaceRegionCountries", replaceRoleDataScopes: "replaceRoleDataScopes", @@ -828,6 +832,13 @@ export const API_ENDPOINTS: Record = { permission: "overview:view", permissions: ["overview:view"] }, + dashboardUserProfileOverview: { + method: "GET", + operationId: API_OPERATIONS.dashboardUserProfileOverview, + path: "/v1/dashboard/user-profile-overview", + permission: "overview:view", + permissions: ["overview:view"] + }, debitCoinSellerStock: { method: "POST", operationId: API_OPERATIONS.debitCoinSellerStock, @@ -1164,6 +1175,13 @@ export const API_ENDPOINTS: Record = { permission: "cumulative-recharge-reward:view", permissions: ["cumulative-recharge-reward:view"] }, + getFinanceCoinSellerRechargeExchangeRate: { + method: "GET", + operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate, + path: "/v1/admin/finance/coin-seller-recharge-exchange-rates/{app_code}", + permission: "coin-seller:exchange-rate", + permissions: ["coin-seller:exchange-rate"] + }, getFinanceScope: { method: "GET", operationId: API_OPERATIONS.getFinanceScope, @@ -2141,6 +2159,13 @@ export const API_ENDPOINTS: Record = { permission: "policy-instance:publish", permissions: ["policy-instance:publish"] }, + quoteFinanceCoinSellerRechargeOrder: { + method: "POST", + operationId: API_OPERATIONS.quoteFinanceCoinSellerRechargeOrder, + path: "/v1/admin/finance/orders/coin-seller-recharges/quotes", + permission: "finance-order:coin-seller-recharge:create", + permissions: ["finance-order:coin-seller-recharge:create"] + }, recyclePrettyId: { method: "POST", operationId: API_OPERATIONS.recyclePrettyId, @@ -2195,6 +2220,13 @@ export const API_ENDPOINTS: Record = { permission: "coin-seller:exchange-rate", permissions: ["coin-seller:exchange-rate"] }, + replaceFinanceCoinSellerRechargeExchangeRate: { + method: "PUT", + operationId: API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate, + path: "/v1/admin/finance/coin-seller-recharge-exchange-rates/{app_code}", + permission: "coin-seller:exchange-rate", + permissions: ["coin-seller:exchange-rate"] + }, replaceRegionBlocks: { method: "PUT", operationId: API_OPERATIONS.replaceRegionBlocks, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index f688b79..448f516 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -2488,6 +2488,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/finance/orders/coin-seller-recharges/quotes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["quoteFinanceCoinSellerRechargeOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/finance/orders/coin-seller-recharges/receipt-verifications": { parameters: { query?: never; @@ -2536,6 +2552,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getFinanceCoinSellerRechargeExchangeRate"]; + put: operations["replaceFinanceCoinSellerRechargeExchangeRate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/payment/temporary-links": { parameters: { query?: never; @@ -3576,6 +3608,22 @@ export interface paths { patch?: never; trace?: never; }; + "/dashboard/user-profile-overview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["dashboardUserProfileOverview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/statistics/overview": { parameters: { query?: never; @@ -4886,6 +4934,9 @@ export interface components { ApiResponseDashboardOverview: components["schemas"]["Envelope"] & { data?: components["schemas"]["DashboardOverview"]; }; + ApiResponseDashboardUserProfileOverview: components["schemas"]["Envelope"] & { + data?: components["schemas"]["DashboardUserProfileOverview"]; + }; ApiResponseEmpty: components["schemas"]["Envelope"]; ApiResponseObject: components["schemas"]["Envelope"] & { data?: { @@ -4965,6 +5016,12 @@ export interface components { ApiResponseFinanceCoinSellerRechargeOrder: components["schemas"]["Envelope"] & { data?: components["schemas"]["FinanceCoinSellerRechargeOrder"]; }; + ApiResponseFinanceCoinSellerRechargeQuote: components["schemas"]["Envelope"] & { + data?: components["schemas"]["FinanceCoinSellerRechargeQuote"]; + }; + ApiResponseFinanceCoinSellerRechargeExchangeRate: components["schemas"]["Envelope"] & { + data?: components["schemas"]["FinanceCoinSellerRechargeExchangeRate"]; + }; ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & { data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"]; }; @@ -5311,6 +5368,24 @@ export interface components { DashboardOverview: { [key: string]: unknown; }; + DashboardUserDistributionItem: { + key: string; + label: string; + count: number; + }; + DashboardUserProfileOverview: { + appCode: string; + /** @description 已完成资料且排除机器人、快捷账号的自然用户总数 */ + totalUsers: number; + newUsers: number; + activeUsers: number; + /** @description 已完成资料且排除机器人、快捷账号的自然用户性别分布 */ + genderDistribution: components["schemas"]["DashboardUserDistributionItem"][]; + /** @description 自然用户最近一次成功登录时上报的 App 版本号分布 */ + appVersionDistribution: components["schemas"]["DashboardUserDistributionItem"][]; + /** Format: int64 */ + updatedAtMs: number; + }; Envelope: { code?: number; data?: unknown; @@ -5644,7 +5719,8 @@ export interface components { appCode: string; targetUserId: string; usdAmount: number; - coinAmount: number; + /** @description 补单时为 true;订单正常计入充值,但服务端强制金币数为 0 */ + isMakeup?: boolean; /** @enum {string} */ providerCode: "mifapay" | "v5pay" | "usdt"; externalOrderNo: string; @@ -5654,6 +5730,50 @@ export interface components { chain?: "TRON" | "BSC"; remark?: string; }; + FinanceCoinSellerRechargeQuoteInput: { + appCode: string; + targetUserId: string; + usdAmount: number; + }; + FinanceCoinSellerRechargeQuote: { + appCode: string; + targetUserId: string; + targetDisplayUserId?: string; + usdAmount: string; + coinAmount: number; + coinsPerUsd: number; + /** @enum {string} */ + rateSource: "tier" | "whitelist"; + matchedUserId?: string; + minUsdAmount?: string; + maxUsdAmount?: string; + }; + FinanceCoinSellerRechargeExchangeRateTier: { + minUsdAmount: string; + maxUsdAmount: string; + coinsPerUsd: number; + }; + FinanceCoinSellerRechargeExchangeRateWhitelist: { + userId: string; + coinsPerUsd: number; + }; + FinanceCoinSellerRechargeExchangeRate: { + appCode: string; + tiers: components["schemas"]["FinanceCoinSellerRechargeExchangeRateTier"][]; + whitelist: components["schemas"]["FinanceCoinSellerRechargeExchangeRateWhitelist"][]; + updatedAtMs: number; + }; + FinanceCoinSellerRechargeExchangeRateInput: { + tiers: { + minUsdAmount: number; + maxUsdAmount: number; + coinsPerUsd: number; + }[]; + whitelist: { + userId: string; + coinsPerUsd: number; + }[]; + }; FinanceCoinSellerRechargeReceiptVerification: { verified: boolean; providerCode?: string; @@ -5913,6 +6033,15 @@ export interface components { }; }; /** @description OK */ + DashboardUserProfileOverviewResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseDashboardUserProfileOverview"]; + }; + }; + /** @description OK */ VipProgramResponse: { headers: { [name: string]: unknown; @@ -5985,6 +6114,24 @@ export interface components { }; }; /** @description OK */ + FinanceCoinSellerRechargeQuoteResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeQuote"]; + }; + }; + /** @description OK */ + FinanceCoinSellerRechargeExchangeRateResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeExchangeRate"]; + }; + }; + /** @description OK */ FinanceCoinSellerRechargeReceiptVerificationResponse: { headers: { [name: string]: unknown; @@ -6281,6 +6428,16 @@ export interface components { "application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"]; }; }; + FinanceCoinSellerRechargeQuoteRequest: { + content: { + "application/json": components["schemas"]["FinanceCoinSellerRechargeQuoteInput"]; + }; + }; + FinanceCoinSellerRechargeExchangeRateRequest: { + content: { + "application/json": components["schemas"]["FinanceCoinSellerRechargeExchangeRateInput"]; + }; + }; FinanceCoinSellerRechargeReceiptVerificationRequest: { content: { "application/json": components["schemas"]["FinanceCoinSellerRechargeReceiptVerificationInput"]; @@ -9247,6 +9404,8 @@ export interface operations { end_ms?: number; app_codes?: string; region_id?: number; + /** @description 逗号分隔的大区 ID;显式区域筛选时用于按 Finance 口径补逐日渠道。 */ + region_ids?: string; }; header?: never; path?: never; @@ -9485,6 +9644,18 @@ export interface operations { 201: components["responses"]["FinanceCoinSellerRechargeOrderResponse"]; }; }; + quoteFinanceCoinSellerRechargeOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeQuoteRequest"]; + responses: { + 200: components["responses"]["FinanceCoinSellerRechargeQuoteResponse"]; + }; + }; verifyFinanceCoinSellerRechargeReceipt: { parameters: { query?: never; @@ -9525,6 +9696,34 @@ export interface operations { 200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"]; }; }; + getFinanceCoinSellerRechargeExchangeRate: { + parameters: { + query?: never; + header?: never; + path: { + app_code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["FinanceCoinSellerRechargeExchangeRateResponse"]; + }; + }; + replaceFinanceCoinSellerRechargeExchangeRate: { + parameters: { + query?: never; + header?: never; + path: { + app_code: string; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeExchangeRateRequest"]; + responses: { + 200: components["responses"]["FinanceCoinSellerRechargeExchangeRateResponse"]; + }; + }; listTemporaryPaymentLinks: { parameters: { query?: never; @@ -10703,6 +10902,23 @@ export interface operations { 200: components["responses"]["DashboardOverviewResponse"]; }; }; + dashboardUserProfileOverview: { + parameters: { + query: { + app_code: string; + stat_tz: string; + start_ms: number; + end_ms: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["DashboardUserProfileOverviewResponse"]; + }; + }; statisticsOverview: { parameters: { query?: { diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index bbfcb5a..af42df9 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -197,6 +197,29 @@ export interface DashboardOverviewDto { usersTotal: number; } +export interface DashboardUserDistributionItemDto { + count: number; + key: string; + label: string; +} + +export interface DashboardUserProfileOverviewDto { + activeUsers: number; + appCode: string; + appVersionDistribution: DashboardUserDistributionItemDto[]; + genderDistribution: DashboardUserDistributionItemDto[]; + newUsers: number; + totalUsers: number; + updatedAtMs: number; +} + +export interface DashboardUserProfileOverviewQuery { + appCode: string; + endMs: number; + startMs: number; + statTz: string; +} + export interface LogDto { action?: string; createdAt?: string; diff --git a/src/shared/charts/DistributionPieChart.jsx b/src/shared/charts/DistributionPieChart.jsx new file mode 100644 index 0000000..ac02441 --- /dev/null +++ b/src/shared/charts/DistributionPieChart.jsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from "react"; +import { readCssToken } from "@/shared/utils/cssTokens.js"; +import { EChart } from "./EChart.jsx"; + +const DEFAULT_COLOR_TOKENS = ["--chart-blue", "--chart-green", "--chart-orange", "--chart-red", "--info", "--stopped"]; +const countFormatter = new Intl.NumberFormat("zh-CN"); + +export function DistributionPieChart({ ariaLabel, data = [], emptyText = "当前无数据" }) { + const reducedMotion = usePrefersReducedMotion(); + const items = useMemo(() => normalizeDistributionItems(data), [data]); + const option = useMemo(() => createDistributionPieOption(items, reducedMotion), [items, reducedMotion]); + + if (!items.length) { + return ( +
+ {emptyText} +
+ ); + } + + return ( +
+ +
+ {items.map((item) => ( +
+ + {item.label} + {countFormatter.format(item.count)} +
+ ))} +
+
+ ); +} + +export function normalizeDistributionItems(data) { + // 过滤非法值和零值,避免 ECharts 生成不可见扇区却仍在 tooltip/图例中展示伪数据。 + return (Array.isArray(data) ? data : []) + .map((item, index) => { + const count = Number(item?.count); + const colorToken = item?.colorToken || DEFAULT_COLOR_TOKENS[index % DEFAULT_COLOR_TOKENS.length]; + return { + color: readCssToken(colorToken, "#64748b"), + count, + key: String(item?.key || `distribution-${index}`), + label: String(item?.label || item?.key || "未知") + }; + }) + .filter((item) => Number.isFinite(item.count) && item.count > 0); +} + +export function createDistributionPieOption(items, reducedMotion = false) { + return { + animation: !reducedMotion, + animationDuration: reducedMotion ? 0 : 450, + animationDurationUpdate: reducedMotion ? 0 : 300, + color: items.map((item) => item.color), + tooltip: { + trigger: "item", + backgroundColor: readCssToken("--chart-tooltip-bg", "#ffffff"), + borderColor: readCssToken("--chart-tooltip-border", "#d8e0ec"), + textStyle: { color: readCssToken("--chart-tooltip-text", "#0f172a") }, + valueFormatter: (value) => `${countFormatter.format(Number(value) || 0)} 人` + }, + series: [ + { + type: "pie", + radius: ["44%", "72%"], + center: ["50%", "50%"], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + itemStyle: { + borderColor: readCssToken("--bg-card", "#ffffff"), + borderWidth: 2 + }, + emphasis: { scaleSize: 6 }, + data: items.map((item) => ({ name: item.label, value: item.count })) + } + ] + }; +} + +function usePrefersReducedMotion() { + const [reducedMotion, setReducedMotion] = useState(() => readReducedMotionPreference()); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return undefined; + } + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = (event) => setReducedMotion(event.matches); + setReducedMotion(media.matches); + media.addEventListener?.("change", update); + return () => media.removeEventListener?.("change", update); + }, []); + + return reducedMotion; +} + +function readReducedMotionPreference() { + return typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; +} diff --git a/src/shared/charts/DistributionPieChart.test.jsx b/src/shared/charts/DistributionPieChart.test.jsx new file mode 100644 index 0000000..c73746a --- /dev/null +++ b/src/shared/charts/DistributionPieChart.test.jsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +vi.mock("./EChart.jsx", () => ({ + EChart: ({ className }) =>
+})); + +import { + createDistributionPieOption, + DistributionPieChart, + normalizeDistributionItems +} from "./DistributionPieChart.jsx"; + +test("shows the standard empty state when every distribution count is zero or invalid", () => { + render( + + ); + + expect(screen.getByRole("status", { name: "空分布" })).toHaveTextContent("当前无数据"); + expect(screen.queryByTestId("distribution-echart")).not.toBeInTheDocument(); +}); + +test("keeps a long distribution in a bounded legend instead of expanding the chart card", () => { + const data = Array.from({ length: 12 }, (_, index) => ({ + count: 12 - index, + key: `version-${index}`, + label: `2.0.${index}` + })); + const { container } = render(); + + expect(screen.getAllByRole("listitem")).toHaveLength(12); + expect(container.querySelector(".distribution-pie__legend")).toBeInTheDocument(); + expect(screen.getByTestId("distribution-echart")).toBeInTheDocument(); +}); + +test("disables ECharts canvas animation when reduced motion is requested", () => { + const items = normalizeDistributionItems([{ count: 8, key: "female", label: "女" }]); + + expect(createDistributionPieOption(items, true)).toMatchObject({ + animation: false, + animationDuration: 0, + animationDurationUpdate: 0 + }); + expect(createDistributionPieOption(items, false)).toMatchObject({ + animation: true, + animationDuration: 450, + animationDurationUpdate: 300 + }); +}); + +test("maps normalized distribution rows into ECharts pie series", () => { + const items = normalizeDistributionItems([ + { count: 8, key: "female", label: "女" }, + { count: 5, key: "male", label: "男" } + ]); + + expect(createDistributionPieOption(items).series[0].data).toEqual([ + { name: "女", value: 8 }, + { name: "男", value: 5 } + ]); +}); diff --git a/src/shared/ui/AdminUserIdentity.jsx b/src/shared/ui/AdminUserIdentity.jsx index e15d132..18dbe29 100644 --- a/src/shared/ui/AdminUserIdentity.jsx +++ b/src/shared/ui/AdminUserIdentity.jsx @@ -8,6 +8,7 @@ export function AdminUserIdentity({ defaultDisplayUserId, displayUserId, name, + namePrefix, onClick, openInAppUserDetail = false, prettyDisplayUserId, @@ -67,6 +68,7 @@ export function AdminUserIdentity({
+ {namePrefix ? {namePrefix} : null} {normalized.displayName}
{visibleRows.length ? ( diff --git a/src/shared/ui/AdminUserIdentity.module.css b/src/shared/ui/AdminUserIdentity.module.css index 352d4d4..b6c38ed 100644 --- a/src/shared/ui/AdminUserIdentity.module.css +++ b/src/shared/ui/AdminUserIdentity.module.css @@ -80,6 +80,12 @@ gap: var(--space-2); } +.namePrefix { + display: inline-flex; + flex: 0 0 auto; + align-items: center; +} + .name { min-width: 0; overflow: hidden; diff --git a/src/shared/ui/AdminUserIdentity.test.jsx b/src/shared/ui/AdminUserIdentity.test.jsx index 64d0d05..eb5bb63 100644 --- a/src/shared/ui/AdminUserIdentity.test.jsx +++ b/src/shared/ui/AdminUserIdentity.test.jsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { setAccessToken } from "@/shared/api/request"; @@ -136,9 +137,11 @@ test("app user detail provider fetches detail and renders current pretty id", as render( - - - + + + + + , ); diff --git a/src/shared/ui/SideDrawer.jsx b/src/shared/ui/SideDrawer.jsx index 5e082c2..b1ca677 100644 --- a/src/shared/ui/SideDrawer.jsx +++ b/src/shared/ui/SideDrawer.jsx @@ -15,12 +15,29 @@ export function SideDrawer({ width = "default", ...props }) { - const drawerClassName = ["side-drawer", width === "wide" ? "side-drawer--wide" : "", className] + const widthClassName = + width === "wide" ? "side-drawer--wide" : width === "detail" ? "side-drawer--detail" : ""; + const drawerClassName = ["side-drawer", widthClassName, className] .filter(Boolean) .join(" "); + const backdropSlotProps = drawerProps.slotProps?.backdrop || {}; + const resolvedDrawerProps = { + ...drawerProps, + slotProps: { + ...drawerProps.slotProps, + backdrop: { + ...backdropSlotProps, + // 抽屉需要保留父页面上下文,使用全局半透明遮罩 token,避免 MUI 默认遮罩过暗。 + sx: [ + { backgroundColor: "var(--overlay-scrim)" }, + ...(Array.isArray(backdropSlotProps.sx) ? backdropSlotProps.sx : [backdropSlotProps.sx].filter(Boolean)), + ], + }, + }, + }; return ( - +
{title ?

{title}

: } diff --git a/src/shared/utils/time.js b/src/shared/utils/time.js index 94dc7d4..8d8660a 100644 --- a/src/shared/utils/time.js +++ b/src/shared/utils/time.js @@ -85,6 +85,66 @@ export function formatMillis(value, timeZone = activeTimeZone) { return formatDateTime(value, timeZone); } +export function startOfDayMillis(value = Date.now(), timeZone = activeTimeZone) { + const normalizedTimeZone = normalizeTimeZone(timeZone); + const targetParts = dateTimeParts(value, normalizedTimeZone); + if (!targetParts) { + return null; + } + + return localDateStartMillis(targetParts, normalizedTimeZone); +} + +export function nextDayStartMillis(value = Date.now(), timeZone = activeTimeZone) { + const normalizedTimeZone = normalizeTimeZone(timeZone); + const targetParts = dateTimeParts(value, normalizedTimeZone); + if (!targetParts) { + return null; + } + + // 先按日历推进一天,再把该日期的本地零点换算成 epoch;不能直接加 24 小时, + // 否则未来开放夏令时时区后会在切换日提前或延后一小时刷新 Dashboard。 + const nextCalendarDate = new Date( + Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day) + 1) + ); + return localDateStartMillis( + { + day: String(nextCalendarDate.getUTCDate()).padStart(2, "0"), + month: String(nextCalendarDate.getUTCMonth() + 1).padStart(2, "0"), + year: String(nextCalendarDate.getUTCFullYear()) + }, + normalizedTimeZone + ); +} + +function localDateStartMillis(targetParts, timeZone) { + const targetUtc = Date.UTC(Number(targetParts.year), Number(targetParts.month) - 1, Number(targetParts.day)); + let candidate = targetUtc; + + // IANA 时区的 UTC 偏移可能随日期变化;迭代校正能在不引入时区库的前提下得到目标日期的本地零点。 + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidateParts = dateTimeParts(candidate, timeZone); + if (!candidateParts) { + return null; + } + const representedUtc = Date.UTC( + Number(candidateParts.year), + Number(candidateParts.month) - 1, + Number(candidateParts.day), + Number(candidateParts.hour), + Number(candidateParts.minute), + Number(candidateParts.second) + ); + const corrected = targetUtc - (representedUtc - candidate); + if (corrected === candidate) { + return corrected; + } + candidate = corrected; + } + + return candidate; +} + function finitePositiveMillis(value) { return Number.isFinite(value) && value > 0 ? value : null; } diff --git a/src/shared/utils/time.test.js b/src/shared/utils/time.test.js index b227df2..031673c 100644 --- a/src/shared/utils/time.test.js +++ b/src/shared/utils/time.test.js @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { formatMillis, normalizeTimeZone, toEpochMillis } from "./time.js"; +import { formatMillis, nextDayStartMillis, normalizeTimeZone, startOfDayMillis, toEpochMillis } from "./time.js"; describe("time formatting", () => { test("formats the same epoch milliseconds in UTC and China time", () => { @@ -18,4 +18,18 @@ describe("time formatting", () => { expect(normalizeTimeZone("UTC")).toBe("UTC"); expect(normalizeTimeZone("America/Los_Angeles")).toBe("Asia/Shanghai"); }); + + test("calculates the current local day boundary for dashboard statistics", () => { + const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0); + + expect(startOfDayMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 9, 0, 0, 0)); + expect(startOfDayMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 9, 16, 0, 0)); + }); + + test("calculates the next local day boundary used for dashboard rollover", () => { + const epochMillis = Date.UTC(2026, 4, 9, 19, 21, 0); + + expect(nextDayStartMillis(epochMillis, "UTC")).toBe(Date.UTC(2026, 4, 10, 0, 0, 0)); + expect(nextDayStartMillis(epochMillis, "Asia/Shanghai")).toBe(Date.UTC(2026, 4, 10, 16, 0, 0)); + }); }); diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 42d9477..adb03ae 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1222,6 +1222,10 @@ width: min(560px, 100vw); } +.side-drawer--detail { + width: min(720px, 100vw); +} + .side-drawer__header { display: flex; flex: 0 0 auto;