增加首页的展示

This commit is contained in:
zhx 2026-07-12 00:47:34 +08:00
parent f597cb6110
commit 2bdc48e63c
59 changed files with 3554 additions and 406 deletions

View File

@ -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"],

View File

@ -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");

View File

@ -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 });
}

View File

@ -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 })));

View File

@ -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);

View File

@ -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;

View 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();
});

View File

@ -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) => {

View File

@ -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);
}

View File

@ -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));
});

View File

@ -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}
/>

View File

@ -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,

View File

@ -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), {

View File

@ -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",

View 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}` };
}

View File

@ -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();
});

View File

@ -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}`,

View File

@ -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: {

View File

@ -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: "",

View File

@ -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),

View File

@ -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: [

View File

@ -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;

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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",

View File

@ -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>

View File

@ -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,

View File

@ -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();

View File

@ -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() {

View 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();
}

View File

@ -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)) {

View 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";
}

View 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>;
}

View File

@ -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",

View File

@ -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 的金币流水" }));

View File

@ -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),

View 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);
});

View File

@ -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
}
});
}

View File

@ -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()]
}));
}

View File

@ -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);
}

View 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>
);
}

View File

@ -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("当前无数据");
});

View File

@ -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;
}
}

View File

@ -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);
}

View 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"
});
});

View File

@ -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 />
</>
);
}

View File

@ -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,

View File

@ -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?: {

View File

@ -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;

View 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;
}

View 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 }
]);
});

View File

@ -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 ? (

View File

@ -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;

View File

@ -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>,
);

View File

@ -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 />}

View File

@ -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;
}

View File

@ -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));
});
});

View File

@ -1222,6 +1222,10 @@
width: min(560px, 100vw);
}
.side-drawer--detail {
width: min(720px, 100vw);
}
.side-drawer__header {
display: flex;
flex: 0 0 auto;