增加首页的展示
This commit is contained in:
parent
f597cb6110
commit
2bdc48e63c
@ -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"],
|
||||
|
||||
@ -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(<DatabiApp />);
|
||||
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");
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 })));
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
22
databi/src/social/metrics.test.js
Normal file
22
databi/src/social/metrics.test.js
Normal file
@ -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();
|
||||
});
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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), {
|
||||
|
||||
@ -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",
|
||||
|
||||
245
finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx
Normal file
245
finance/src/components/FinanceCoinSellerExchangeRateDialog.jsx
Normal file
@ -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 (
|
||||
<Dialog fullWidth maxWidth="md" open={open} onClose={disabled ? undefined : onClose}>
|
||||
<DialogTitle>币商充值金币汇率</DialogTitle>
|
||||
<DialogContent>
|
||||
<form className="finance-rate-config" id="coin-seller-exchange-rate-form" onSubmit={submit}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="APP"
|
||||
select
|
||||
value={appCode}
|
||||
onChange={(event) => setAppCode(event.target.value)}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<RateSection
|
||||
actionLabel="添加区间"
|
||||
columns={["最低 USD", "最高 USD", "1 USD 兑换金币"]}
|
||||
disabled={disabled}
|
||||
rows={config.tiers}
|
||||
title="金额区间"
|
||||
onAdd={() => addRow("tiers")}
|
||||
onRemove={(id) => removeRow("tiers", id)}
|
||||
renderRow={(item) => (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
slotProps={{ htmlInput: { "aria-label": "最低 USD", min: 0.01, step: "0.01" } }}
|
||||
type="number"
|
||||
value={item.minUsdAmount}
|
||||
onChange={(event) => updateRow("tiers", item.id, "minUsdAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
slotProps={{ htmlInput: { "aria-label": "最高 USD", min: 0.01, step: "0.01" } }}
|
||||
type="number"
|
||||
value={item.maxUsdAmount}
|
||||
onChange={(event) => updateRow("tiers", item.id, "maxUsdAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
slotProps={{ htmlInput: { "aria-label": "区间金币汇率", min: 1, step: "1" } }}
|
||||
type="number"
|
||||
value={item.coinsPerUsd}
|
||||
onChange={(event) => updateRow("tiers", item.id, "coinsPerUsd", event.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<RateSection
|
||||
actionLabel="添加白名单"
|
||||
columns={["用户 ID", "1 USD 兑换金币"]}
|
||||
disabled={disabled}
|
||||
rows={config.whitelist}
|
||||
title="用户 ID 白名单"
|
||||
onAdd={() => addRow("whitelist")}
|
||||
onRemove={(id) => removeRow("whitelist", id)}
|
||||
renderRow={(item) => (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
slotProps={{ htmlInput: { "aria-label": "白名单用户 ID" } }}
|
||||
value={item.userId}
|
||||
onChange={(event) => updateRow("whitelist", item.id, "userId", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
slotProps={{ htmlInput: { "aria-label": "白名单金币汇率", min: 1, step: "1" } }}
|
||||
type="number"
|
||||
value={item.coinsPerUsd}
|
||||
onChange={(event) => updateRow("whitelist", item.id, "coinsPerUsd", event.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={disabled} variant="outlined" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled || !appCode} form="coin-seller-exchange-rate-form" type="submit" variant="contained">
|
||||
保存
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RateSection({ actionLabel, columns, disabled, onAdd, onRemove, renderRow, rows, title }) {
|
||||
return (
|
||||
<section className="finance-rate-config__section">
|
||||
<div className="finance-rate-config__heading">
|
||||
<h3>{title}</h3>
|
||||
<Button disabled={disabled} size="small" startIcon={<AddOutlined fontSize="small" />} onClick={onAdd}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="finance-rate-config__table">
|
||||
<div className={`finance-rate-config__row finance-rate-config__row--head finance-rate-config__row--${columns.length}`}>
|
||||
{columns.map((column) => (
|
||||
<span key={column}>{column}</span>
|
||||
))}
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
rows.map((item) => (
|
||||
<div
|
||||
className={`finance-rate-config__row finance-rate-config__row--${columns.length}`}
|
||||
key={item.id}
|
||||
>
|
||||
{renderRow(item)}
|
||||
<Button color="error" disabled={disabled} size="small" onClick={() => onRemove(item.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="finance-rate-config__empty">当前无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function withRowID(item, rowSequence) {
|
||||
rowSequence.current += 1;
|
||||
return { ...item, id: `rate-row-${rowSequence.current}` };
|
||||
}
|
||||
@ -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(
|
||||
<FinanceCoinSellerExchangeRateDialog
|
||||
apps={[{ appCode: "lalu", appName: "Lalu" }]}
|
||||
initialAppCode="lalu"
|
||||
open
|
||||
onClose={onClose}
|
||||
onLoad={onLoad}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
@ -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({
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||
刷新
|
||||
</Button>
|
||||
{canConfigureExchangeRate ? (
|
||||
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
|
||||
金币汇率配置
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={!canCreate}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
@ -377,15 +463,28 @@ export function FinanceCoinSellerRechargeOrderList({
|
||||
open={createOpen}
|
||||
receiptLoading={receiptLoading}
|
||||
receiptVerification={receiptVerification}
|
||||
quote={quote}
|
||||
quoteError={quoteError}
|
||||
quoteLoading={quoteLoading}
|
||||
onChange={updateForm}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setReceiptVerification(null);
|
||||
setFormError("");
|
||||
setQuote(null);
|
||||
setQuoteError("");
|
||||
}}
|
||||
onSubmit={submitCreate}
|
||||
onVerifyReceipt={verifyCreateReceipt}
|
||||
/>
|
||||
<FinanceCoinSellerExchangeRateDialog
|
||||
apps={apps}
|
||||
initialAppCode={filters.appCode}
|
||||
open={rateConfigOpen}
|
||||
onClose={() => setRateConfigOpen(false)}
|
||||
onLoad={onLoadExchangeRate}
|
||||
onSave={onSaveExchangeRate}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -402,9 +501,12 @@ function CreateDialog({
|
||||
open,
|
||||
receiptLoading,
|
||||
receiptVerification,
|
||||
quote,
|
||||
quoteError,
|
||||
quoteLoading,
|
||||
}) {
|
||||
const disabled = loading || receiptLoading;
|
||||
const canCreate = receiptVerification?.verified === true && !disabled;
|
||||
const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -470,15 +572,23 @@ function CreateDialog({
|
||||
value={form.usdAmount}
|
||||
onChange={(event) => onChange("usdAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
<Button
|
||||
aria-pressed={form.isMakeup}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant={form.isMakeup ? "contained" : "outlined"}
|
||||
onClick={() => onChange("isMakeup", !form.isMakeup)}
|
||||
>
|
||||
补单
|
||||
</Button>
|
||||
<TextField
|
||||
disabled
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
|
||||
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)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
@ -587,6 +697,23 @@ function currencyOptionLabel(option) {
|
||||
return option || "";
|
||||
}
|
||||
|
||||
function quoteHelperText(quote, error, loading, isMakeup) {
|
||||
if (isMakeup) {
|
||||
return "补单不发放金币,充值金额仍正常计入统计";
|
||||
}
|
||||
if (loading) {
|
||||
return "正在计算金币";
|
||||
}
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
if (!quote) {
|
||||
return "输入 APP、用户 ID 和 USD 金额后自动生成";
|
||||
}
|
||||
const source = quote.rateSource === "whitelist" ? `白名单 ${quote.matchedUserId || ""}`.trim() : `${quote.minUsdAmount}-${quote.maxUsdAmount} USD`;
|
||||
return `${source} · 1 USD = ${formatAmount(quote.coinsPerUsd)} 金币`;
|
||||
}
|
||||
|
||||
function ReceiptVerificationBanner({ verification }) {
|
||||
const detail = [
|
||||
verification.status && `状态 ${verification.status}`,
|
||||
|
||||
@ -8,6 +8,12 @@ afterEach(() => {
|
||||
|
||||
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(
|
||||
<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />,
|
||||
<FinanceCoinSellerRechargeOrderList
|
||||
{...baseProps()}
|
||||
onCreate={onCreate}
|
||||
onQuote={onQuote}
|
||||
onVerifyReceipt={onVerifyReceipt}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FinanceCoinSellerRechargeOrderList
|
||||
{...baseProps()}
|
||||
onCreate={onCreate}
|
||||
onQuote={onQuote}
|
||||
onVerifyReceipt={onVerifyReceipt}
|
||||
/>,
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<Routes>
|
||||
{publicRoutes.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
|
||||
))}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to={defaultAdminPath} replace />} />
|
||||
{adminRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<RequirePermission code={route.permission}>
|
||||
<DeferredPage route={route} />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<AppUserDetailProvider>
|
||||
<Routes>
|
||||
{publicRoutes.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
|
||||
))}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to={defaultAdminPath} replace />} />
|
||||
{adminRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<RequirePermission code={route.permission}>
|
||||
<DeferredPage route={route} />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={defaultAdminPath} replace />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<Navigate to={defaultAdminPath} replace />} />
|
||||
</Routes>
|
||||
</AppUserDetailProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
||||
<Header
|
||||
activeLabel={activeNav?.label || "总览"}
|
||||
activeLabel={activeRoute?.label || activeNav?.label || "总览"}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
menuItems={menus}
|
||||
onRefresh={refresh}
|
||||
|
||||
@ -225,6 +225,7 @@ export const MENU_CODES = {
|
||||
logsOperations: "logs-operations",
|
||||
appUsers: "app-users",
|
||||
appUserList: "app-user-list",
|
||||
appUserDetail: "app-user-detail",
|
||||
appUserBans: "app-user-bans",
|
||||
appUserLoginLogs: "app-user-login-logs",
|
||||
appUserLevelConfig: "app-user-level-config",
|
||||
|
||||
@ -6,7 +6,6 @@ import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
||||
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
|
||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
|
||||
import { theme } from "@/theme.js";
|
||||
|
||||
export function AppProviders({ children }) {
|
||||
@ -19,7 +18,7 @@ export function AppProviders({ children }) {
|
||||
<AuthProvider>
|
||||
<AppScopeProvider>
|
||||
<TimeZoneProvider>
|
||||
<AppUserDetailProvider>{children}</AppUserDetailProvider>
|
||||
{children}
|
||||
</TimeZoneProvider>
|
||||
</AppScopeProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
||||
<div className={styles.detailHero}>
|
||||
<AdminUserIdentity user={user} size="large" />
|
||||
<AppUserStatus status={user.status} />
|
||||
</div>
|
||||
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>身份信息</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="用户 ID" value={user.userId} />
|
||||
<DetailItem label="短 ID" value={user.defaultDisplayUserId || user.displayUserId || user.userId} />
|
||||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||||
<DetailItem label="昵称" value={user.username} />
|
||||
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
||||
<DetailItem label="生日" value={user.birth} />
|
||||
<DetailItem label="年龄" value={formatAge(user.age)} />
|
||||
<DetailItem label="注册设备" value={user.registerDevice} />
|
||||
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>等级信息</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="富豪等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
||||
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
||||
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
||||
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||||
<DetailItem label="VIP 到期时间" value={formatVipExpire(user.vip)} />
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||||
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
||||
<DetailItem label="封禁原因" value={user.ban?.reason} />
|
||||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||
<DetailItem label="国家" value={formatCountry(user)} />
|
||||
<DetailItem
|
||||
label="区域"
|
||||
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
||||
/>
|
||||
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
||||
<DetailItem
|
||||
label="最新操作"
|
||||
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
||||
/>
|
||||
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>资产余额</h3>
|
||||
<AssetBalanceList balances={balances} />
|
||||
</section>
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>当前佩戴</h3>
|
||||
<ResourceList resources={equippedResources} />
|
||||
</section>
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>资源资产</h3>
|
||||
<ResourceList resources={resources} />
|
||||
</section>
|
||||
<SideDrawer
|
||||
contentClassName={styles.detailDrawerBody}
|
||||
open={open}
|
||||
title="用户详情"
|
||||
width="detail"
|
||||
onClose={onClose}
|
||||
>
|
||||
<AppUserDetailView
|
||||
fullDetailsHref={`/app/users/${encodeURIComponent(user.userId)}`}
|
||||
loading={loading}
|
||||
user={user}
|
||||
onOpenFullDetails={onClose}
|
||||
/>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }) {
|
||||
const isElement = value && typeof value === "object";
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<span className={styles.detailValue}>
|
||||
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrettyValue({ user }) {
|
||||
if (!user.prettyId || !user.prettyDisplayUserId) {
|
||||
return "-";
|
||||
}
|
||||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
||||
}
|
||||
|
||||
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 <div className={styles.emptyInline}>当前无数据</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.assetList}>
|
||||
{balances.map((balance) => (
|
||||
<div className={styles.assetRow} key={balance.assetType}>
|
||||
<div>
|
||||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||||
</div>
|
||||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceList({ resources }) {
|
||||
if (!resources.length) {
|
||||
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||
}
|
||||
return (
|
||||
<ul className={styles.resourceList}>
|
||||
{resources.map((resource) => (
|
||||
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
||||
<ResourceThumb resource={resource} />
|
||||
<div className={styles.resourceBody}>
|
||||
<div className={styles.resourceTitle}>
|
||||
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
||||
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||||
</div>
|
||||
<div className={styles.resourceMeta}>
|
||||
{formatResourceType(resource.resourceType)} · 剩余{" "}
|
||||
{formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
||||
</div>
|
||||
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceThumb({ resource }) {
|
||||
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||||
if (!imageUrl) {
|
||||
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
||||
}
|
||||
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
||||
}
|
||||
|
||||
function AppUserStatus({ status }) {
|
||||
const tone = status === "active" ? "running" : "danger";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{appUserStatusLabels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@ -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(
|
||||
<ToastProvider>
|
||||
<AppUserDetailProvider>
|
||||
<DetailTrigger />
|
||||
</AppUserDetailProvider>
|
||||
<MemoryRouter initialEntries={["/app/users?status=active"]}>
|
||||
<AppUserDetailProvider>
|
||||
<DetailTrigger />
|
||||
</AppUserDetailProvider>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
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() {
|
||||
|
||||
428
src/features/app-users/components/AppUserDetailView.jsx
Normal file
428
src/features/app-users/components/AppUserDetailView.jsx
Normal file
@ -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 (
|
||||
<div className={[styles.detailView, mode === "page" ? styles.detailViewPage : ""].filter(Boolean).join(" ")}>
|
||||
<div className={styles.detailStickyHeader}>
|
||||
<div className={styles.detailHero}>
|
||||
<AdminUserIdentity
|
||||
namePrefix={<GenderAgeTag age={user.age} gender={user.gender} />}
|
||||
size="large"
|
||||
user={user}
|
||||
/>
|
||||
<div className={styles.detailHeroActions}>
|
||||
<AppUserStatus status={user.status} />
|
||||
{fullDetailsHref ? (
|
||||
<Button
|
||||
component={Link}
|
||||
state={{ from: `${location.pathname}${location.search}${location.hash}` }}
|
||||
to={fullDetailsHref}
|
||||
type={undefined}
|
||||
onClick={onOpenFullDetails}
|
||||
>
|
||||
<OpenInNewOutlined fontSize="small" />
|
||||
完整详情
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailMetrics}>
|
||||
<SummaryMetric label="财富" value={formatSummaryLevel(user.levels?.wealth)} />
|
||||
<SummaryMetric label="魅力" value={formatSummaryLevel(user.levels?.charm)} />
|
||||
<SummaryMetric label="游戏" value={formatSummaryLevel(user.levels?.game)} />
|
||||
<SummaryMetric label="VIP" value={formatVipSummary(user.vip)} />
|
||||
<SummaryMetric label="金币" value={formatNumber(user.coin)} />
|
||||
<SummaryMetric label="钻石" value={formatNumber(user.diamond)} />
|
||||
<SummaryMetric label="累计充值" value={formatCompactUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||
</div>
|
||||
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
|
||||
<Tabs
|
||||
aria-label="用户详情分类"
|
||||
className={styles.detailTabs}
|
||||
value={activeTab}
|
||||
variant="scrollable"
|
||||
onChange={(_, value) => setActiveTab(value)}
|
||||
>
|
||||
{detailTabs.map(([value, label]) => (
|
||||
<Tab
|
||||
className={styles.detailTab}
|
||||
key={value}
|
||||
label={value === "resources" ? `${label} (${equippedResources.length + resources.length})` : label}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailTabPanel} role="tabpanel">
|
||||
{activeTab === "overview" ? <OverviewPanel user={user} /> : null}
|
||||
{activeTab === "assets" ? <AssetsPanel balances={balances} user={user} /> : null}
|
||||
{activeTab === "resources" ? (
|
||||
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
|
||||
) : null}
|
||||
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewPanel({ user }) {
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="身份信息">
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="用户 ID" value={<CopyableValue label="复制用户 ID" value={user.userId} />} />
|
||||
<DetailItem
|
||||
label="短 ID"
|
||||
value={
|
||||
<CopyableValue
|
||||
label="复制短 ID"
|
||||
value={user.defaultDisplayUserId || user.displayUserId || user.userId}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||||
<DetailItem label="昵称" value={user.username} />
|
||||
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
||||
<DetailItem label="生日" value={user.birth} />
|
||||
<DetailItem label="年龄" value={formatAge(user.age)} />
|
||||
<DetailItem label="注册设备" value={user.registerDevice} />
|
||||
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
<DetailSection title="等级与会员">
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="财富等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
||||
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
||||
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
||||
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||||
<DetailItem label="VIP 到期" value={formatVipExpire(user.vip)} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetsPanel({ balances, user }) {
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="资产概览">
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
<DetailSection title={`资产余额 (${balances.length})`}>
|
||||
<AssetBalanceList balances={balances} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcesPanel({ equippedResources, resources }) {
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={`当前佩戴 (${equippedResources.length})`}>
|
||||
<ResourceList resources={equippedResources} />
|
||||
</DetailSection>
|
||||
<DetailSection title={`资源资产 (${resources.length})`}>
|
||||
<ResourceList resources={resources} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPanel({ user }) {
|
||||
return (
|
||||
<DetailSection title="账号状态与记录">
|
||||
<div className={styles.detailGrid}>
|
||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||||
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
||||
<DetailItem label="封禁原因" value={user.ban?.reason} wide />
|
||||
<DetailItem label="国家" value={formatCountry(user)} />
|
||||
<DetailItem label="区域" value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")} />
|
||||
<DetailItem label="最新登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
||||
<DetailItem
|
||||
label="最新操作"
|
||||
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
||||
wide
|
||||
/>
|
||||
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ children, title }) {
|
||||
return (
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value, wide = false }) {
|
||||
const isElement = value && typeof value === "object";
|
||||
return (
|
||||
<div className={[styles.detailItem, wide ? styles.detailItemWide : ""].filter(Boolean).join(" ")}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<span className={styles.detailValue}>
|
||||
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryMetric({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailMetric}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={styles.copyableDetailValue}>
|
||||
<span>{normalized}</span>
|
||||
<button aria-label={label} className={styles.copyDetailButton} title={copied ? "已复制" : label} type="button" onClick={copy}>
|
||||
<ContentCopyOutlined fontSize="inherit" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PrettyValue({ user }) {
|
||||
if (!user.prettyId || !user.prettyDisplayUserId) {
|
||||
return "-";
|
||||
}
|
||||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
||||
}
|
||||
|
||||
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 <div className={styles.emptyInline}>当前无数据</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.assetList}>
|
||||
{balances.map((balance) => (
|
||||
<div className={styles.assetRow} key={balance.assetType}>
|
||||
<div>
|
||||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||||
</div>
|
||||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceList({ resources }) {
|
||||
if (!resources.length) {
|
||||
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||
}
|
||||
return (
|
||||
<ul className={styles.resourceList}>
|
||||
{resources.map((resource) => (
|
||||
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
||||
<ResourceThumb resource={resource} />
|
||||
<div className={styles.resourceBody}>
|
||||
<div className={styles.resourceTitle}>
|
||||
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
||||
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||||
</div>
|
||||
<div className={styles.resourceMeta}>
|
||||
{formatResourceType(resource.resourceType)} · 剩余 {formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
||||
</div>
|
||||
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceThumb({ resource }) {
|
||||
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||||
if (!imageUrl) {
|
||||
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
||||
}
|
||||
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
||||
}
|
||||
|
||||
function AppUserStatus({ status }) {
|
||||
const tone = status === "active" ? "running" : "danger";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{appUserStatusLabels[status] || status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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 <span className={styles.genderAgeUnknown}>年龄 {ageLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={`${normalizedGender === "female" ? "女" : "男"},${ageLabel} 岁`}
|
||||
className={normalizedGender === "female" ? styles.genderAgeFemale : styles.genderAgeMale}
|
||||
>
|
||||
{normalizedGender === "female" ? "♀" : "♂"} {ageLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserLevelsValue({ levels }) {
|
||||
const entries = [
|
||||
["财富", levels?.wealth],
|
||||
["魅力", levels?.charm],
|
||||
["游戏", levels?.game],
|
||||
];
|
||||
return (
|
||||
<div className={styles.userLevels}>
|
||||
{entries.map(([label, level]) => (
|
||||
<span className={styles.userLevel} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{level ? `Lv${numericLevel(level.displayLevel ?? level.realLevel)}` : "-"}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
||||
61
src/features/app-users/pages/AppUserDetailPage.jsx
Normal file
61
src/features/app-users/pages/AppUserDetailPage.jsx
Normal file
@ -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 <PageSkeleton />;
|
||||
}
|
||||
|
||||
const pageError = normalizedUserId ? error : "缺少用户 ID";
|
||||
const returnPath = resolveReturnPath(location.state?.from);
|
||||
|
||||
return (
|
||||
<div className={styles.detailPageRoot}>
|
||||
<div className={styles.detailPageToolbar}>
|
||||
<div className={styles.detailPageTitleGroup}>
|
||||
<Button onClick={() => navigate(returnPath)}>
|
||||
<ArrowBackOutlined fontSize="small" />
|
||||
返回用户列表
|
||||
</Button>
|
||||
<h1>用户详情</h1>
|
||||
</div>
|
||||
<Button disabled={loading || !normalizedUserId} onClick={reload}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.detailPageContent}>
|
||||
<DataState error={pageError} loading={false} onRetry={normalizedUserId ? reload : undefined}>
|
||||
{data ? <AppUserDetailView mode="page" user={data} /> : null}
|
||||
</DataState>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveReturnPath(value) {
|
||||
const path = String(value || "");
|
||||
// 只恢复站内绝对路径,避免路由 state 被外部输入污染后形成开放跳转。
|
||||
return path.startsWith("/") && !path.startsWith("//") ? path : "/app/users";
|
||||
}
|
||||
56
src/features/app-users/pages/AppUserDetailPage.test.jsx
Normal file
56
src/features/app-users/pages/AppUserDetailPage.test.jsx
Normal file
@ -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(
|
||||
<QueryProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{
|
||||
pathname: "/app/users/10001",
|
||||
state: { from: "/app/users?status=active" },
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/app/users/:userId" element={<AppUserDetailPage />} />
|
||||
<Route path="/app/users" element={<LocationValue />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryProvider>,
|
||||
);
|
||||
|
||||
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 <span data-testid="location-value">{`${location.pathname}${location.search}`}</span>;
|
||||
}
|
||||
@ -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) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
||||
render: (user) => (
|
||||
<AdminUserIdentity
|
||||
namePrefix={<GenderAgeTag age={user.age} gender={user.gender} />}
|
||||
openInAppUserDetail
|
||||
user={user}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "gender",
|
||||
label: "性别",
|
||||
width: "minmax(72px, 0.4fr)",
|
||||
render: (user) => <GenderValue gender={user.gender} />,
|
||||
},
|
||||
{
|
||||
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) => <LevelValue level={user.levels?.wealth} />,
|
||||
},
|
||||
{
|
||||
key: "charmLevel",
|
||||
label: "魅力等级",
|
||||
width: "minmax(118px, 0.7fr)",
|
||||
render: (user) => <LevelValue level={user.levels?.charm} />,
|
||||
},
|
||||
{
|
||||
key: "gameLevel",
|
||||
label: "游戏等级",
|
||||
width: "minmax(96px, 0.55fr)",
|
||||
render: (user) => <LevelValue level={user.levels?.game} />,
|
||||
key: "levels",
|
||||
label: "财富/魅力/游戏等级",
|
||||
width: "minmax(230px, 1.15fr)",
|
||||
render: (user) => <UserLevelsValue levels={user.levels} />,
|
||||
},
|
||||
{
|
||||
key: "roles",
|
||||
|
||||
@ -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 的金币流水" }));
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
44
src/features/dashboard/api.test.ts
Normal file
44
src/features/dashboard/api.test.ts
Normal file
@ -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);
|
||||
});
|
||||
@ -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<DashboardOverviewDto> {
|
||||
return apiRequest<DashboardOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardOverview));
|
||||
}
|
||||
|
||||
export function getDashboardUserProfileOverview(
|
||||
query: DashboardUserProfileOverviewQuery
|
||||
): Promise<DashboardUserProfileOverviewDto> {
|
||||
// app_code 明确参与统计聚合;请求层仍会附带 X-App-Code,供后端执行应用数据权限校验。
|
||||
return apiRequest<DashboardUserProfileOverviewDto>(apiEndpointPath(API_OPERATIONS.dashboardUserProfileOverview), {
|
||||
query: {
|
||||
app_code: query.appCode,
|
||||
end_ms: query.endMs,
|
||||
start_ms: query.startMs,
|
||||
stat_tz: query.statTz
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<section className="overview-charts" aria-label="资源图表">
|
||||
<ChartCard title="后台用户" colorToken="--chart-blue" series={overview?.series?.users || []} />
|
||||
<ChartCard title="操作日志" colorToken="--chart-green" series={overview?.series?.operations || []} />
|
||||
<section className="overview-charts" aria-label="用户画像分布">
|
||||
<Card className="chart-card dashboard-distribution-card" component="article">
|
||||
<div className="card-head dashboard-distribution-card__head">
|
||||
<h3 className="card-title">性别人数分布</h3>
|
||||
<span>自然用户 · 已完成资料 · 排除机器人/快捷账号</span>
|
||||
</div>
|
||||
<DistributionPieChart ariaLabel="自然用户性别人数分布饼图" data={genderDistribution} />
|
||||
</Card>
|
||||
<Card className="chart-card dashboard-distribution-card" component="article">
|
||||
<div className="card-head dashboard-distribution-card__head">
|
||||
<h3 className="card-title">App 版本号人数分布</h3>
|
||||
<span>自然用户 · 最近一次成功登录版本</span>
|
||||
</div>
|
||||
<DistributionPieChart ariaLabel="自然用户最近一次成功登录 App 版本号人数分布饼图" data={appVersionDistribution} />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function withGenderColors(distribution) {
|
||||
return (Array.isArray(distribution) ? distribution : []).map((item) => ({
|
||||
...item,
|
||||
// 性别颜色按稳定 key 固定,避免后端排序变化导致同一性别在刷新后换色。
|
||||
colorToken: GENDER_COLOR_TOKENS[String(item?.key || "").toLowerCase()]
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<section className="kpi-grid" aria-label="系统指标">
|
||||
<KpiCard icon={DnsOutlined} label="后台用户" value={overview?.usersTotal || 0} unit="个" sub={<span className="status-ok">启用 {stats.activeUsers}</span>} />
|
||||
<KpiCard icon={DeveloperBoardOutlined} label="角色数量" value={stats.rolesTotal} unit="个" sub={`菜单 ${stats.menuTotal}`} />
|
||||
<KpiCard icon={StorageOutlined} label="今日操作" value={stats.logsToday} unit="条" tone="info" sub="操作日志" />
|
||||
<section className="kpi-grid dashboard-profile-kpis" aria-label="用户核心指标">
|
||||
<KpiCard
|
||||
icon={PeopleAltOutlined}
|
||||
label="总人数"
|
||||
value={formatCount(overview?.totalUsers)}
|
||||
unit="人"
|
||||
sub="已完成资料,排除机器人/快捷账号"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PersonAddAltOutlined}
|
||||
label="当天新增"
|
||||
value={formatCount(overview?.newUsers)}
|
||||
unit="人"
|
||||
tone="success"
|
||||
sub="按当前显示时区统计"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={OnlinePredictionOutlined}
|
||||
label="当前日活"
|
||||
value={formatCount(overview?.activeUsers)}
|
||||
unit="人"
|
||||
tone="info"
|
||||
sub="当天去重活跃用户"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
const count = Number(value);
|
||||
return countFormatter.format(Number.isFinite(count) && count > 0 ? count : 0);
|
||||
}
|
||||
|
||||
73
src/features/dashboard/components/DashboardUserProfile.jsx
Normal file
73
src/features/dashboard/components/DashboardUserProfile.jsx
Normal file
@ -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 (
|
||||
<section className="dashboard-profile-section" aria-labelledby="dashboard-profile-title">
|
||||
<div className="dashboard-profile-head">
|
||||
<h2 id="dashboard-profile-title">用户画像</h2>
|
||||
{page.overview?.updatedAtMs ? <span>数据更新时间 {formatMillis(page.overview.updatedAtMs)}</span> : null}
|
||||
</div>
|
||||
{renderContent(page)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent(page) {
|
||||
if (page.loading) {
|
||||
return <DashboardUserProfileSkeleton />;
|
||||
}
|
||||
if (page.error) {
|
||||
return (
|
||||
<div className="dashboard-profile-state" role="alert">
|
||||
<span>{page.error}</span>
|
||||
<Button onClick={page.reload}>
|
||||
<Refresh fontSize="small" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!page.overview) {
|
||||
return (
|
||||
<div className="dashboard-profile-state" role="status">
|
||||
当前无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardStats overview={page.overview} />
|
||||
<DashboardCharts overview={page.overview} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardUserProfileSkeleton() {
|
||||
return (
|
||||
<div className="dashboard-profile-skeleton" aria-hidden="true">
|
||||
<div className="dashboard-profile-skeleton__kpis">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton animation="wave" className="dashboard-profile-skeleton__kpi" key={index} variant="rounded" />
|
||||
))}
|
||||
</div>
|
||||
<div className="dashboard-profile-skeleton__charts">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton animation="wave" className="dashboard-profile-skeleton__chart" key={index} variant="rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 }) => (
|
||||
<div aria-label={ariaLabel}>{data.map((item) => `${item.label}:${item.count}`).join("|")}</div>
|
||||
)
|
||||
}));
|
||||
|
||||
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(<DashboardUserProfile />);
|
||||
|
||||
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(<DashboardUserProfile />);
|
||||
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(<DashboardUserProfile />);
|
||||
|
||||
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(<DashboardUserProfile />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("当前无数据");
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
74
src/features/dashboard/hooks/useDashboardPage.test.jsx
Normal file
74
src/features/dashboard/hooks/useDashboardPage.test.jsx
Normal file
@ -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"
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,11 @@
|
||||
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
||||
import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx";
|
||||
|
||||
export function OverviewPage() {
|
||||
return <DashboardWorkspaces />;
|
||||
return (
|
||||
<>
|
||||
<DashboardWorkspaces />
|
||||
<DashboardUserProfile />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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<ApiOperationId, ApiEndpoint> = {
|
||||
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,
|
||||
|
||||
218
src/shared/api/generated/schema.d.ts
vendored
218
src/shared/api/generated/schema.d.ts
vendored
@ -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?: {
|
||||
|
||||
@ -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;
|
||||
|
||||
106
src/shared/charts/DistributionPieChart.jsx
Normal file
106
src/shared/charts/DistributionPieChart.jsx
Normal file
@ -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 (
|
||||
<div aria-label={ariaLabel} className="distribution-pie-empty" role="status">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={ariaLabel} className="distribution-pie" role="group">
|
||||
<EChart className="distribution-pie__chart" option={option} />
|
||||
<div className="distribution-pie__legend" role="list">
|
||||
{items.map((item) => (
|
||||
<div className="distribution-pie__legend-row" key={item.key} role="listitem" title={item.label}>
|
||||
<span className="distribution-pie__legend-dot" style={{ backgroundColor: item.color }} />
|
||||
<span className="distribution-pie__legend-label">{item.label}</span>
|
||||
<strong>{countFormatter.format(item.count)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
67
src/shared/charts/DistributionPieChart.test.jsx
Normal file
67
src/shared/charts/DistributionPieChart.test.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("./EChart.jsx", () => ({
|
||||
EChart: ({ className }) => <div className={className} data-testid="distribution-echart" />
|
||||
}));
|
||||
|
||||
import {
|
||||
createDistributionPieOption,
|
||||
DistributionPieChart,
|
||||
normalizeDistributionItems
|
||||
} from "./DistributionPieChart.jsx";
|
||||
|
||||
test("shows the standard empty state when every distribution count is zero or invalid", () => {
|
||||
render(
|
||||
<DistributionPieChart
|
||||
ariaLabel="空分布"
|
||||
data={[
|
||||
{ count: 0, key: "zero", label: "零" },
|
||||
{ count: "invalid", key: "invalid", label: "无效" }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
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(<DistributionPieChart ariaLabel="版本分布" data={data} />);
|
||||
|
||||
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 }
|
||||
]);
|
||||
});
|
||||
@ -8,6 +8,7 @@ export function AdminUserIdentity({
|
||||
defaultDisplayUserId,
|
||||
displayUserId,
|
||||
name,
|
||||
namePrefix,
|
||||
onClick,
|
||||
openInAppUserDetail = false,
|
||||
prettyDisplayUserId,
|
||||
@ -67,6 +68,7 @@ export function AdminUserIdentity({
|
||||
</span>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.nameLine}>
|
||||
{namePrefix ? <span className={styles.namePrefix}>{namePrefix}</span> : null}
|
||||
<span className={styles.name}>{normalized.displayName}</span>
|
||||
</div>
|
||||
{visibleRows.length ? (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
<ToastProvider>
|
||||
<AppUserDetailProvider>
|
||||
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
|
||||
</AppUserDetailProvider>
|
||||
<MemoryRouter>
|
||||
<AppUserDetailProvider>
|
||||
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
|
||||
</AppUserDetailProvider>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
|
||||
@ -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 (
|
||||
<Drawer anchor="right" open={open} onClose={onClose} {...drawerProps}>
|
||||
<Drawer anchor="right" open={open} onClose={onClose} {...resolvedDrawerProps}>
|
||||
<Component className={drawerClassName} {...props}>
|
||||
<div className="side-drawer__header">
|
||||
{title ? <h2 className="side-drawer__title">{title}</h2> : <span />}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1222,6 +1222,10 @@
|
||||
width: min(560px, 100vw);
|
||||
}
|
||||
|
||||
.side-drawer--detail {
|
||||
width: min(720px, 100vw);
|
||||
}
|
||||
|
||||
.side-drawer__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user