Compare commits

...

12 Commits
test ... main

Author SHA1 Message Date
zhx
2f384687b5 积分收益政策h5 2026-07-12 17:38:51 +08:00
zhx
a4dd49c33d 增加用户信息 2026-07-12 14:17:50 +08:00
zhx
2bdc48e63c 增加首页的展示 2026-07-12 00:47:34 +08:00
zhx
f597cb6110 feat: rename manager cross-region permission 2026-07-11 20:36:42 +08:00
zhx
8ef5b0c08d feat: add manager region invite switch 2026-07-11 18:27:19 +08:00
zhx
c206c72612 完善后台管理功能 2026-07-11 02:26:23 +08:00
zhx
9065382192 fix: scope H5 config by app code 2026-07-10 20:20:58 +08:00
zhx
ef260df749 ui相关修复 2026-07-10 14:31:56 +08:00
zhx
cf98d5bd9a 子币商以及 充值流水相关 2026-07-09 18:15:25 +08:00
zhx
c7ca4f23ec 数据改动 2026-07-09 16:16:31 +08:00
zhx
50da0a569b 后台子币商权限相关 2026-07-09 13:02:40 +08:00
zhx
5e9ed4c0c5 Add ops center lucky gift platform 2026-07-08 22:30:46 +08:00
164 changed files with 24942 additions and 1127 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import {
fetchSocialBiKpi,
fetchSocialBiMaster,
fetchSocialBiOverview,
fetchSocialBiRequirements,
fetchStatisticsOverview
} from "./api.js";
@ -18,6 +19,7 @@ vi.mock("./api.js", () => ({
fetchSocialBiKpi: vi.fn(),
fetchSocialBiMaster: vi.fn(),
fetchSocialBiOverview: vi.fn(),
fetchSocialBiRequirements: vi.fn(),
fetchSelfGameStatisticsOverview: vi.fn(),
fetchStatisticsOverview: vi.fn(),
getCurrentAppCode: vi.fn(() => "lalu"),
@ -58,12 +60,14 @@ beforeEach(() => {
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
fetchSocialBiRequirements.mockResolvedValue({ columns: [], daily_rows: [], section: "new_users", total: null });
});
afterEach(() => {
window.history.pushState(null, "", "/");
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
test("keeps current data visible during scheduled refresh", async () => {
@ -330,8 +334,8 @@ 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).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
expect(fetchSocialBiKpi).not.toHaveBeenCalled();
// App Hero 12,300 + 5,000 USD = $173
// App Lalu App $123 Top5
@ -355,11 +359,130 @@ 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);
});
test("renders social BI data requirements table with section filters and CSV export", async () => {
window.history.pushState(null, "", "/databi/social/?view=table&regions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", daily_series: [], kind: "hyapp", total: {} }]
});
fetchSocialBiRequirements.mockImplementation(async ({ section }) => {
if (section === "revenue") {
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "recharge_users", label: "充值用户", type: "count" },
{ key: "recharge_usd_minor", label: "充值金额", type: "money_minor" }
],
daily_series: [{ metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "total" }
}]
}]
};
}
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "app_download_users", label: "下载APP人数", type: "count" },
{ key: "registration_success_rate", label: "注册成功率", type: "ratio" }
],
daily_series: [{ metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "total" }
}]
}]
};
});
render(<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");
await act(async () => {
screen.getByRole("radio", { name: "数据需求" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
payerType: "all",
regionIds: [9, 11],
section: "new_users",
userRole: "all"
}));
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("下载APP人数")).toBeTruthy();
expect(screen.getByText("注册成功率")).toBeTruthy();
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
expect(screen.getAllByText("66.66%").length).toBeGreaterThan(0);
expect(screen.queryByRole("radiogroup", { name: "用户身份" })).toBeNull();
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
await act(async () => {
screen.getByRole("tab", { name: "营收" }).click();
});
await flushEffects();
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
await act(async () => {
screen.getByRole("radio", { name: "主播" }).click();
});
await flushEffects();
await act(async () => {
screen.getByRole("radio", { name: "已付费" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({
payerType: "paid",
section: "revenue",
userRole: "host"
}));
expect(screen.getByText("充值用户")).toBeTruthy();
expect(screen.getByText("充值金额")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
const urlMock = { createObjectURL: vi.fn(() => "blob:social-bi-requirements"), revokeObjectURL: vi.fn() };
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.stubGlobal("URL", urlMock);
await act(async () => {
screen.getByRole("button", { name: "导出 CSV" }).click();
});
expect(urlMock.createObjectURL).toHaveBeenCalledTimes(1);
expect(anchorClick).toHaveBeenCalledTimes(1);
anchorClick.mockRestore();
});
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
window.history.pushState(null, "", "/databi/social/?view=funnel");
fetchSocialBiMaster.mockResolvedValue({

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,10 +176,46 @@ 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 });
}
export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
const query = {};
if (statTz) {
query.stat_tz = statTz;
}
if (startMs) {
query.start_ms = String(startMs);
}
if (endMs) {
query.end_ms = String(endMs);
}
if (appCodes?.length) {
query.app_codes = appCodes.join(",");
}
if (regionId) {
query.region_id = String(regionId);
} else if (regionIds?.length) {
query.region_ids = regionIds.join(",");
}
if (section) {
query.section = section;
}
if (userRole) {
query.user_role = userRole;
}
if (payerType) {
query.payer_type = payerType;
}
if (newUserType) {
query.new_user_type = newUserType;
}
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
}
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
const query = {};
if (statTz) {

View File

@ -1,5 +1,14 @@
import { afterEach, expect, test, vi } from "vitest";
import { fetchFilterOptions, fetchSocialBiFilterOptions, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js";
import {
fetchFilterOptions,
fetchSocialBiFilterOptions,
fetchSocialBiOverview,
fetchSocialBiRequirements,
fetchStatisticsOverview,
getCurrentAppCode,
getDefaultAppOptions,
setCurrentAppCode
} from "./api.js";
afterEach(() => {
window.localStorage.clear();
@ -87,6 +96,48 @@ test("loads social BI apps and operation users from admin APIs", async () => {
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
});
test("loads social BI requirements with section role and payer filters", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
const data = await fetchSocialBiRequirements({
appCodes: ["lalu", "huwaa"],
endMs: 20,
payerType: "paid",
regionIds: [9, 11],
section: "revenue",
startMs: 10,
statTz: "Asia/Shanghai",
userRole: "host"
});
const requestURL = new URL(String(fetch.mock.calls[0][0]));
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/requirements");
expect(requestURL.searchParams.get("app_codes")).toBe("lalu,huwaa");
expect(requestURL.searchParams.get("section")).toBe("revenue");
expect(requestURL.searchParams.get("user_role")).toBe("host");
expect(requestURL.searchParams.get("payer_type")).toBe("paid");
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
expect(data.total.recharge_users).toBe(2);
});
test("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);
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;
@ -249,6 +261,24 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
return out;
}
// averageMetricMaps跨天"平均值"行。可加字段取日均(总量/覆盖天数),余额快照保持末日快照,
// 比例/均值类分子分母同除天数后不变,直接沿用汇总行的派生值。
export function averageMetricMaps(rows, dayCount) {
if (!rows.length || !dayCount || dayCount <= 0) {
return null;
}
const out = aggregateMetricMaps(rows, { acrossTime: true });
ADDITIVE_METRIC_KEYS.forEach((key) => {
if (SNAPSHOT_METRIC_KEYS.has(key)) {
return;
}
if (out[key] !== null && out[key] !== undefined) {
out[key] = out[key] / dayCount;
}
});
return out;
}
// App 品牌色chips、图表系列、行标识统一取色保证同一 App 全站同色。
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];

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

@ -2,24 +2,28 @@
// 视图不直接调 API只消费这里的产物原始 snake_case 指标行 + 维度字段)。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview } from "../api.js";
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.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) {
export function useSocialBiData(filters, { includeFunnel = false, includeKpi = false } = {}) {
const [master, setMaster] = useState(null);
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮master 到达后立刻重打一轮(整轮浪费)。
const [isMasterSettled, setIsMasterSettled] = useState(false);
const [overview, setOverview] = useState(null);
const [funnel, setFunnel] = useState(null);
const [kpi, setKpi] = useState(null);
const [requirements, setRequirements] = useState(null);
const [requirementsError, setRequirementsError] = useState("");
const [isRequirementsLoading, setIsRequirementsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState([]);
const [refreshToken, setRefreshToken] = useState(0);
const requestSeq = useRef(0);
const requirementsSeq = useRef(0);
useEffect(() => {
let ignore = false;
@ -65,6 +69,10 @@ export function useSocialBiData(filters) {
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) {
@ -72,13 +80,28 @@ export function useSocialBiData(filters) {
}
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);
Promise.allSettled([
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
]).then(([overviewResult, funnelResult, kpiResult]) => {
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
const funnelRequest = includeFunnel
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
: Promise.resolve(null);
// 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;
}
@ -94,7 +117,9 @@ export function useSocialBiData(filters) {
setOverview(null);
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
}
if (funnelResult.status === "fulfilled") {
if (!includeFunnel) {
setFunnel(null);
} else if (funnelResult.status === "fulfilled") {
setFunnel(funnelResult.value);
(funnelResult.value?.apps || []).forEach((app) => {
if (app.error) {
@ -105,7 +130,9 @@ export function useSocialBiData(filters) {
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}`);
@ -117,7 +144,60 @@ export function useSocialBiData(filters) {
setErrors(nextErrors);
setIsLoading(false);
});
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
}, [appCodes, funnelAppCodes, includeFunnel, includeKpi, isMasterSettled, master?.access, overviewRequestScopes, range, refreshToken]);
const loadRequirements = useCallback(
({ newUserType, payerType, section, userRole } = {}) => {
if (!isMasterSettled) {
return Promise.resolve(null);
}
const sequence = ++requirementsSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
setIsRequirementsLoading(true);
setRequirementsError("");
setRequirements(null);
if (!requestScopes.length) {
const emptyResult = { access: overview?.access || null, apps: [] };
setRequirements(emptyResult);
setIsRequirementsLoading(false);
return Promise.resolve(emptyResult);
}
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
...scope,
endMs,
newUserType,
payerType,
section,
startMs,
statTz: SOCIAL_BI_TZ,
userRole
})))
.then((results) => {
if (sequence !== requirementsSeq.current) {
return null;
}
const data = mergeRequirementResponses(results);
setRequirements(data);
return data;
})
.catch((error) => {
if (sequence === requirementsSeq.current) {
setRequirements(null);
setRequirementsError(error?.message || "数据需求加载失败");
}
return null;
})
.finally(() => {
if (sequence === requirementsSeq.current) {
setIsRequirementsLoading(false);
}
});
},
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
);
const refresh = useCallback(() => {
setRefreshToken((current) => current + 1);
@ -132,10 +212,15 @@ export function useSocialBiData(filters) {
funnel,
funnelAppCodes,
isLoading,
isRequirementsLoading,
kpi,
loadRequirements,
master,
overview,
range,
refreshToken,
requirements,
requirementsError,
refresh
};
}
@ -236,3 +321,136 @@ function buildRegionByCountryIndex(master) {
});
return index;
}
function buildRequirementRequestScopes(appCodes, regions) {
const selectedApps = new Set((appCodes || []).filter(Boolean));
if (!regions?.length || regions.includes(ALL)) {
return [{ appCodes: [...selectedApps] }];
}
const byApp = new Map();
regions.forEach((selection) => {
const value = String(selection || "").trim();
if (!value || value === ALL) {
return;
}
if (value.startsWith("app:")) {
const appCode = value.slice(4).trim().toLowerCase();
if (appCode) {
byApp.set(appCode, null);
}
return;
}
const [rawAppCode, rawRegionID] = value.split(":");
const appCode = String(rawAppCode || "").trim().toLowerCase();
const regionID = Number(rawRegionID);
if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) {
return;
}
if (!byApp.has(appCode)) {
byApp.set(appCode, new Set());
}
byApp.get(appCode).add(regionID);
});
const scopes = [];
byApp.forEach((regionIDs, appCode) => {
if (selectedApps.size && !selectedApps.has(appCode)) {
return;
}
if (regionIDs === null) {
scopes.push({ appCodes: [appCode] });
return;
}
const ids = [...regionIDs].sort((left, right) => left - right);
if (ids.length === 1) {
scopes.push({ appCodes: [appCode], regionId: ids[0] });
} else if (ids.length > 1) {
scopes.push({ appCodes: [appCode], regionIds: ids });
}
});
return scopes;
}
function 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) => {
if (Array.isArray(item?.apps)) {
merged.apps.push(...item.apps);
}
});
return merged;
}

View File

@ -1,19 +1,20 @@
// DataTableView Excel / / /
// / CSV snake_case metrics.js
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import { useSocialBi } from "../SocialBiApp.jsx";
import {
METRIC_GROUPS,
aggregateMetricMaps,
averageMetricMaps,
formatMetric,
metricChartValue,
metricLabel,
metricTooltip
} from "../metrics.js";
import { downloadCsv, isBlank } from "../format.js";
import { downloadCsv, formatCount, formatDurationMs, formatMoneyMinor, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./data-table-view.css";
@ -39,6 +40,126 @@ const DIMENSIONS = [
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
];
const TABLE_MODES = [
{ key: "wide", label: "运营宽表" },
{ key: "requirements", label: "数据需求" }
];
const REQUIREMENT_SECTIONS = [
{
csvName: "p0-new-users",
fallbackColumns: [
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
{ key: "new_users", label: "新增注册", type: "count" },
{ key: "profile_complete_users", label: "完善资料", type: "count" },
{ key: "first_room_join_users", label: "首次进房", type: "count" }
],
key: "new_users",
label: "新用户"
},
{
csvName: "p1-revenue",
fallbackColumns: [
{ key: "recharge_usd_minor", label: "充值", type: "money_minor" },
{ key: "recharge_users", label: "充值用户", type: "count" },
{ key: "first_recharge_users", label: "首充用户", type: "count" },
{ key: "repeat_recharge_users", label: "复购用户", type: "count" },
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
],
key: "revenue",
label: "营收",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
{
csvName: "p2-retention",
fallbackColumns: [
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
],
key: "retention",
label: "留存",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
{
csvName: "p2-active",
fallbackColumns: [
{ key: "active_users", label: "活跃用户", type: "count" },
{ key: "room_join_users", label: "进房用户", type: "count" },
{ key: "mic_users", label: "上麦用户", type: "count" },
{ key: "message_users", label: "发言用户", type: "count" },
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
],
key: "active",
label: "活跃",
newUserFilter: true,
payerFilter: true,
roleFilter: true
},
{
csvName: "p4-gift",
fallbackColumns: [
{ key: "gift_panel_open_users", label: "礼物面板用户", type: "count" },
{ key: "gift_senders", label: "送礼用户", type: "count" },
{ key: "gift_coin_spent", label: "礼物流水", type: "coin" },
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
],
key: "gift",
label: "送礼",
newUserFilter: true,
roleFilter: true
},
{
csvName: "p5-game",
fallbackColumns: [
{ key: "probability_game_panel_open_users", label: "概率游戏面板用户", type: "count" },
{ key: "game_players", label: "押注用户", type: "count" },
{ key: "game_turnover", label: "游戏流水", type: "coin" },
{ key: "game_profit", label: "游戏利润", type: "coin" },
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
],
key: "game",
label: "游戏",
newUserFilter: true,
roleFilter: true
}
];
const USER_ROLE_OPTIONS = [
{ label: "全部角色", value: "all" },
{ label: "主播", value: "host" },
{ label: "用户", value: "user" }
];
const PAYER_TYPE_OPTIONS = [
{ label: "全部付费", value: "all" },
{ label: "已付费", value: "paid" },
{ label: "未付费", value: "unpaid" }
];
// = =
const NEW_USER_OPTIONS = [
{ label: "全部用户", value: "all" },
{ label: "新用户", value: "new" },
{ label: "非新用户", value: "old" }
];
// P5 game_columns
const FALLBACK_GAME_COLUMNS = [
{ key: "game_bet_users", label: "押注用户数", type: "count" },
{ key: "game_d1_retention_rate", label: "游戏次留", type: "ratio" },
{ key: "game_arppu_coin", label: "ARPPU(金币)", type: "coin" },
{ key: "game_bet_coin", label: "押注金币", type: "coin" }
];
const GAME_SOURCE_LABELS = { self: "自研", vendor: "厂商" };
function readClosedGroups() {
try {
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
@ -89,16 +210,300 @@ function rowIdentity(row, dimKey, index) {
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
}
function requirementSectionConfig(key) {
return REQUIREMENT_SECTIONS.find((section) => section.key === key) || REQUIREMENT_SECTIONS[0];
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function sectionMatches(item, sectionKey) {
return String(item?.section ?? item?.key ?? item?.code ?? "") === sectionKey;
}
function normalizeRequirementColumn(column) {
const key = String(column?.key ?? column?.field ?? column?.name ?? "").trim();
if (!key) {
return null;
}
return {
key,
label: column.label || column.title || column.name || key,
tooltip: column.tooltip || column.description || column.remark || "",
type: column.type || column.value_type || column.format || ""
};
}
function requirementAppContext(app) {
return {
app_code: app?.app_code || app?.appCode || "",
app_name: app?.app_name || app?.appName || app?.app_code || app?.appCode || "",
kind: app?.kind || "",
restricted: Boolean(app?.restricted)
};
}
function flattenRequirementRow(row, context = {}) {
if (!row || typeof row !== "object") {
return null;
}
const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
const flattened = { ...context, ...row, ...metrics };
delete flattened.metrics;
return flattened;
}
function normalizedRequirementColumns(sectionItem, fallbackColumns) {
const columns = asArray(sectionItem?.columns || sectionItem?.fields || sectionItem?.metrics).map(normalizeRequirementColumn).filter(Boolean);
return columns.length ? columns : fallbackColumns;
}
// ""
function sortRequirementDailyRows(rows) {
return [...rows].sort((a, b) => {
const left = String(a?.stat_day || "");
const right = String(b?.stat_day || "");
if (left === right) {
return 0;
}
return left < right ? 1 : -1;
});
}
function normalizeRequirementPayload(payload, sectionKey) {
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
const appSections = asArray(payload?.apps).flatMap((app) => {
const context = requirementAppContext(app);
return asArray(app?.sections)
.filter((section) => sectionMatches(section, sectionKey))
.map((section) => ({ context, section }));
});
if (appSections.length) {
const firstSection = appSections.find(({ section }) => asArray(section?.columns || section?.fields || section?.metrics).length)?.section
|| appSections[0].section;
const columns = normalizedRequirementColumns(firstSection, fallbackColumns);
if (appSections.length === 1) {
const { context, section } = appSections[0];
return {
average: flattenRequirementRow(section.average, context),
columns,
rows: sortRequirementDailyRows(
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean)
),
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
};
}
const rows = appSections.flatMap(({ context, section }) => {
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
const dailyRows = sortRequirementDailyRows(
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
.map((row) => flattenRequirementRow(row, context))
.filter(Boolean)
);
return [total, average, ...dailyRows].filter(Boolean);
});
return { average: null, columns, rows, total: null };
}
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
|| asArray(payload?.items).find((item) => sectionMatches(item, sectionKey) && (item.columns || item.daily_rows || item.dailyRows || item.total || item.totals))
|| payload
|| {};
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
const rows = sortRequirementDailyRows(
asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
.map((row) => flattenRequirementRow(row))
.filter(Boolean)
);
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
return {
average: flattenRequirementRow(sectionItem.average),
columns,
rows,
total
};
}
// P5 [ + ()] app/game game_columns
// //
function normalizeRequirementGamePayload(payload) {
const appSections = asArray(payload?.apps).flatMap((app) => {
const context = requirementAppContext(app);
return asArray(app?.sections)
.filter((section) => sectionMatches(section, "game"))
.map((section) => ({ context, section }));
});
const sections = appSections.length
? appSections
: asArray(payload?.sections)
.filter((section) => sectionMatches(section, "game"))
.map((section) => ({ context: {}, section }));
const columns =
sections
.map(({ section }) => asArray(section.game_columns).map(normalizeRequirementColumn).filter(Boolean))
.find((cols) => cols.length) || FALLBACK_GAME_COLUMNS;
const rows = sections.flatMap(({ context, section }) =>
asArray(section.games).flatMap((game) => {
const gameContext = {
...context,
game_key: game.game_key || "",
game_name: game.game_name || game.game_key || "--",
game_source: game.game_source || ""
};
const total = flattenRequirementRow(game.total, { ...gameContext, stat_day: "汇总" });
const daily = sortRequirementDailyRows(
asArray(game.daily_series)
.map((row) => flattenRequirementRow(row, gameContext))
.filter(Boolean)
);
return total ? [total, ...daily] : daily;
})
);
return { columns, rows };
}
function requirementGameLabel(row) {
const source = GAME_SOURCE_LABELS[String(row?.game_source || "")] || "";
const name = row?.game_name || row?.game_key || "--";
return source ? `${source}·${name}` : name;
}
function inferRequirementType(column) {
const type = String(column.type || "").toLowerCase();
const key = String(column.key || "").toLowerCase();
if (type.includes("money") || key.endsWith("_usd_minor")) {
return "money_minor";
}
if (type.includes("ratio") || type.includes("rate") || type.includes("percent") || key.endsWith("_rate")) {
return "ratio";
}
if (type.includes("duration") || key.endsWith("_ms")) {
return "duration_ms";
}
if (type.includes("coin") || key.endsWith("_coin") || key.endsWith("_turnover") || key.endsWith("_profit")) {
return "coin";
}
return type || "number";
}
// 2 2
function truncateTwoDecimals(value, { pad = false } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "--";
}
const truncated = Math.trunc(numeric * 100) / 100;
return truncated.toLocaleString("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: pad ? 2 : 0
});
}
function formatRequirementRatio(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "--";
}
return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
}
function formatRequirementValue(column, value) {
if (isBlank(value)) {
return "--";
}
const type = inferRequirementType(column);
if (type === "money_minor") {
const dollars = Number(value) / 100;
if (!Number.isFinite(dollars)) {
return "--";
}
return Number.isInteger(dollars) ? formatMoneyMinor(value) : `$${truncateTwoDecimals(dollars, { pad: true })}`;
}
if (type === "ratio") {
return formatRequirementRatio(value);
}
if (type === "duration_ms") {
return formatDurationMs(value);
}
if (type === "coin" || type === "count" || type === "integer") {
const numeric = Number(value);
// /"2"
if (Number.isFinite(numeric) && !Number.isInteger(numeric)) {
return truncateTwoDecimals(numeric, { pad: true });
}
return formatCount(value);
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric, { pad: true });
}
return String(value);
}
function csvRequirementValue(column, value) {
if (isBlank(value)) {
return "";
}
const type = inferRequirementType(column);
if (type === "money_minor") {
return Number(value) / 100;
}
if (type === "ratio") {
return Number(value) * 100;
}
return value;
}
function requirementRowIdentity(row, sectionKey, index) {
const parts = [row.app_code, row.stat_day, row.region_id].filter((part) => part !== undefined && part !== null && part !== "");
return parts.length ? `${sectionKey}:${parts.join("|")}` : `${sectionKey}:${index}`;
}
function requirementAppName(row) {
return row?.app_name || row?.app_code || "全部";
}
export function DataTableView() {
const { derived, isLoading, range } = useSocialBi();
const {
derived,
isLoading,
isRequirementsLoading,
loadRequirements,
range,
refreshToken,
requirements,
requirementsError
} = useSocialBi();
const [mode, setMode] = useState("wide");
const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null);
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all");
const [requirementNewUser, setRequirementNewUser] = useState("all");
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
const hasDay = dim.hasDay;
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
const activeRequirementSection = requirementSectionConfig(requirementSection);
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
const requirementColumns = requirementData.columns;
const requirementRows = requirementData.rows;
const requirementTotal = requirementData.total;
const requirementAverage = requirementData.average;
const requirementGameData = useMemo(
() => (requirementSection === "game" ? normalizeRequirementGamePayload(requirements) : { columns: [], rows: [] }),
[requirements, requirementSection]
);
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
@ -124,16 +529,45 @@ export function DataTableView() {
// /
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
//
const wideDayCount = hasDay ? new Set(sortedRows.map((row) => String(row.stat_day || "")).filter(Boolean)).size : 0;
const averageRow = totalRow && hasDay && wideDayCount > 0 ? averageMetricMaps(sortedRows, wideDayCount) : null;
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
const hasRestricted = baseRows.some((row) => row.restricted);
const columnCount = dimColumns.length + visibleMetrics.length;
useEffect(() => {
if (mode !== "requirements") {
return;
}
loadRequirements({
newUserType: effectiveRequirementNewUser,
payerType: effectiveRequirementPayer,
section: requirementSection,
userRole: effectiveRequirementRole
});
}, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
const handleDimChange = (key) => {
setDimKey(key);
setSort(null);
};
const handleRequirementSectionChange = (key) => {
const nextSection = requirementSectionConfig(key);
setRequirementSection(nextSection.key);
if (!nextSection.roleFilter) {
setRequirementRole("all");
}
if (!nextSection.payerFilter) {
setRequirementPayer("all");
}
if (!nextSection.newUserFilter) {
setRequirementNewUser("all");
}
};
const toggleGroup = (key) => {
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
setClosedGroups(next);
@ -157,9 +591,34 @@ export function DataTableView() {
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let content;
const handleRequirementsExport = () => {
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
const rows = [
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
...requirementRows
].map((row) => [
row.stat_day || "--",
requirementAppName(row),
...requirementColumns.map((column) => csvRequirementValue(column, row[column.key]))
]);
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
const handleGameBreakdownExport = () => {
const header = ["游戏", "日期", "App", ...requirementGameData.columns.map((column) => column.label)];
const rows = requirementGameData.rows.map((row) => [
requirementGameLabel(row),
row.stat_day || "--",
requirementAppName(row),
...requirementGameData.columns.map((column) => csvRequirementValue(column, row[column.key]))
]);
downloadCsv(`social-bi-game-breakdown-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
};
let wideContent;
if (isLoading && !baseRows.length) {
content = (
wideContent = (
<div className="sbi-dt-skeleton" aria-label="数据加载中">
{SKELETON_WIDTHS.map((width) => (
<span className="sbi-skeleton" key={width} style={{ width }} />
@ -167,25 +626,19 @@ export function DataTableView() {
</div>
);
} else if (!baseRows.length) {
content = (
wideContent = (
<div className="sbi-empty">
<strong>当前维度暂无数据</strong>
<span>
{dim.dims.includes("region")
? "该区间没有区域级数据,可能尚未配置区域目录;试试更换日期区间或切换其它维度"
: "试试更换日期区间、调整 App 筛选或切换其它维度"}
</span>
<strong>当前无数据</strong>
</div>
);
} else if (!visibleMetrics.length) {
content = (
wideContent = (
<div className="sbi-empty">
<strong>所有列组已隐藏</strong>
<span>点击上方列组开关恢复需要查看的指标列</span>
</div>
);
} else {
content = (
wideContent = (
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-table">
<thead>
@ -233,6 +686,18 @@ export function DataTableView() {
))}
</tr>
) : null}
{averageRow ? (
<tr className="is-total">
{dimColumns.map((column, index) => (
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
{index === 0 ? "平均值" : column.totalText}
</td>
))}
{visibleMetrics.map((key) => (
<td key={key}>{formatMetric(key, averageRow[key])}</td>
))}
</tr>
) : null}
{renderRows.map((row, rowIndex) => (
<tr key={rowIdentity(row, dim.key, rowIndex)}>
{dimColumns.map((column, index) => (
@ -258,15 +723,152 @@ export function DataTableView() {
);
}
let requirementsContent;
if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
requirementsContent = (
<div className="sbi-dt-skeleton" aria-label="数据需求加载中">
{SKELETON_WIDTHS.map((width) => (
<span className="sbi-skeleton" key={width} style={{ width }} />
))}
</div>
);
} else if (requirementsError) {
requirementsContent = (
<div className="sbi-empty" role="alert">
<strong>{requirementsError}</strong>
</div>
);
} else if (!requirementRows.length && !requirementTotal) {
requirementsContent = (
<div className="sbi-empty">
<strong>当前无数据</strong>
</div>
);
} else {
requirementsContent = (
<>
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-req-table">
<thead>
<tr>
<th className="is-left sbi-dt-sticky">日期</th>
<th className="is-left">App</th>
{requirementColumns.map((column) => (
<th key={column.key}>
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
{column.label}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{requirementTotal ? (
<tr className="is-total">
<td className="is-left sbi-dt-sticky">汇总</td>
<td className="is-left">全部</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, requirementTotal[column.key])}</td>
))}
</tr>
) : null}
{requirementAverage ? (
<tr className="is-total">
<td className="is-left sbi-dt-sticky">平均值</td>
<td className="is-left">全部</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, requirementAverage[column.key])}</td>
))}
</tr>
) : null}
{requirementRows.map((row, rowIndex) => (
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
{requirementColumns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{requirementGameData.rows.length ? (
<>
<div className="sbi-card-header">
<strong>各游戏明细</strong>
<small>押注用户/同游戏次留/ARPPU · 不受身份与新用户筛选影响</small>
</div>
<div className="sbi-table-scroll sbi-dt-scroll">
<table className="sbi-table sbi-dt-req-table">
<thead>
<tr>
<th className="is-left sbi-dt-sticky">游戏</th>
<th className="is-left">日期</th>
<th className="is-left">App</th>
{requirementGameData.columns.map((column) => (
<th key={column.key}>
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
{column.label}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{requirementGameData.rows.map((row, rowIndex) => (
<tr
className={row.stat_day === "汇总" ? "is-total" : undefined}
key={`${row.app_code || ""}|${row.game_key || rowIndex}|${row.stat_day || rowIndex}`}
>
<td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
<td className="is-left">{row.stat_day || "--"}</td>
<td className="is-left">{requirementAppName(row)}</td>
{requirementGameData.columns.map((column) => (
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</>
);
}
const rowCount = mode === "requirements" ? requirementRows.length : sortedRows.length;
const content = mode === "requirements" ? requirementsContent : wideContent;
const exportDisabled = mode === "requirements"
? (!requirementRows.length && !requirementTotal) || isRequirementsLoading
: !sortedRows.length || !visibleMetrics.length;
return (
<section className="sbi-card">
<div className="sbi-card-header">
<strong>数据明细</strong>
<small>
{rangeLabel(range)} · {sortedRows.length}
{rangeLabel(range)} · {rowCount}
</small>
</div>
<div className="sbi-dt-tools">
<div className="sbi-seg" role="radiogroup" aria-label="数据明细模式">
{TABLE_MODES.map((item) => (
<button
aria-checked={mode === item.key}
className={mode === item.key ? "is-active" : ""}
key={item.key}
onClick={() => setMode(item.key)}
role="radio"
type="button"
>
{item.label}
</button>
))}
</div>
{mode === "wide" ? (
<>
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
{DIMENSIONS.map((item) => (
<button
@ -300,10 +902,66 @@ export function DataTableView() {
})}
</div>
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
</>
) : (
<>
<div className="sbi-dt-section-tabs" role="tablist" aria-label="数据需求分组">
{REQUIREMENT_SECTIONS.map((section) => (
<button
aria-selected={activeRequirementSection.key === section.key}
className={activeRequirementSection.key === section.key ? "is-active" : ""}
key={section.key}
onClick={() => handleRequirementSectionChange(section.key)}
role="tab"
type="button"
>
{section.label}
</button>
))}
</div>
{activeRequirementSection.roleFilter ? (
<RequirementFilterGroup
ariaLabel="用户身份"
label="用户身份"
options={USER_ROLE_OPTIONS}
value={effectiveRequirementRole}
onChange={setRequirementRole}
/>
) : null}
{activeRequirementSection.payerFilter ? (
<RequirementFilterGroup
ariaLabel="付费身份"
label="付费身份"
options={PAYER_TYPE_OPTIONS}
value={effectiveRequirementPayer}
onChange={setRequirementPayer}
/>
) : null}
{activeRequirementSection.newUserFilter ? (
<RequirementFilterGroup
ariaLabel="是否新用户"
label="是否新用户"
options={NEW_USER_OPTIONS}
value={effectiveRequirementNewUser}
onChange={setRequirementNewUser}
/>
) : null}
</>
)}
<div className="sbi-dt-export">
{mode === "requirements" && requirementGameData.rows.length ? (
<Button
disabled={!sortedRows.length || !visibleMetrics.length}
onClick={handleExport}
onClick={handleGameBreakdownExport}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
>
导出游戏明细
</Button>
) : null}
<Button
disabled={exportDisabled}
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
@ -316,3 +974,25 @@ export function DataTableView() {
</section>
);
}
function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
return (
<div className="sbi-dt-filter-group">
<span className="sbi-dt-groups-label">{label}</span>
<div className="sbi-seg" role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => (
<button
aria-checked={value === option.value}
className={value === option.value ? "is-active" : ""}
key={option.value}
onClick={() => onChange(option.value)}
role="radio"
type="button"
>
{option.label}
</button>
))}
</div>
</div>
);
}

View File

@ -16,6 +16,37 @@
flex-wrap: wrap;
}
.sbi-dt-section-tabs {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.sbi-dt-section-tabs > button {
padding: 6px 12px;
border: 1px solid var(--sbi-border);
border-radius: 999px;
background: #ffffff;
color: var(--sbi-text-2);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.sbi-dt-section-tabs > button.is-active {
border-color: var(--sbi-accent);
color: var(--sbi-accent);
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
}
.sbi-dt-filter-group {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sbi-dt-groups-label {
color: var(--sbi-text-3);
font-size: 12px;
@ -57,24 +88,32 @@
color: var(--sbi-accent);
}
.sbi-dt-req-table th:first-child,
.sbi-dt-req-table td:first-child {
min-width: 104px;
}
.sbi-dt-sort {
margin-left: 3px;
font-size: 11px;
}
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
.sbi-dt-table .sbi-dt-sticky {
.sbi-dt-table .sbi-dt-sticky,
.sbi-dt-req-table .sbi-dt-sticky {
position: sticky;
left: 0;
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
}
.sbi-dt-table td.sbi-dt-sticky {
.sbi-dt-table td.sbi-dt-sticky,
.sbi-dt-req-table td.sbi-dt-sticky {
z-index: 1;
background: var(--sbi-card);
}
.sbi-dt-table th.sbi-dt-sticky {
.sbi-dt-table th.sbi-dt-sticky,
.sbi-dt-req-table th.sbi-dt-sticky {
z-index: 4;
}

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

@ -20,6 +20,15 @@ server {
proxy_pass http://127.0.0.1:13100/api/;
}
location /ops-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:13114/ops-api/;
}
location = /healthz {
proxy_pass http://127.0.0.1:13100/healthz;
}

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";
@ -45,6 +48,7 @@ import {
validateApplicationPayload,
} from "./format.js";
import { downloadCsv } from "@/shared/api/download";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -61,10 +65,13 @@ function chinaDayStart(timestamp) {
function timePresets() {
const now = Date.now();
const todayStart = chinaDayStart(now);
const yesterdayStart = todayStart - DAY_MS;
const chinaNow = new Date(now + CHINA_OFFSET_MS);
const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS;
return [
{ key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } },
// 00:00 00:00 24
{ key: "yesterday", label: "昨天", range: { endMs: todayStart, startMs: yesterdayStart } },
{ key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } },
{ key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } },
{ key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } },
@ -145,7 +152,7 @@ export function FinanceApp() {
paidState: "",
rechargeType: "",
regionId: "",
timeRange: timePresets().find((preset) => preset.key === "30d").range,
timeRange: timePresets().find((preset) => preset.key === "yesterday").range,
}));
const [rechargeExporting, setRechargeExporting] = useState(false);
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
@ -656,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");
@ -735,7 +761,13 @@ export function FinanceApp() {
}
if (sessionState.error) {
return <StateScreen actionHref="/login" actionText="重新登录" title={sessionState.error} />;
return (
<StateScreen
actionHref={buildLoginRedirectPath(window.location)}
actionText="重新登录"
title={sessionState.error}
/>
);
}
if (!session?.canAccessFinance) {
@ -864,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}
@ -871,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

@ -1,21 +1,55 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { FinanceApp } from "./FinanceApp.jsx";
import {
fetchFinanceApplicationOptions,
fetchFinanceRechargeApps,
fetchFinanceRechargeOverview,
fetchFinanceRechargeSummaries,
fetchFinanceSession,
listFinanceApplications,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
listFinanceWithdrawalApplications,
listMyFinanceApplications
} from "./api.js";
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
vi.mock("./api.js", () => ({
approveFinanceApplication: vi.fn(),
approveFinanceWithdrawalApplication: vi.fn(),
createFinanceApplication: vi.fn(),
createFinanceCoinSellerRechargeOrder: vi.fn(),
fetchFinanceApplicationOptions: vi.fn(async () => ({
apps: [{ appCode: "lalu", appName: "lalu" }],
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
walletIdentities: []
})),
fetchFinanceSession: vi.fn(async () => ({
exportFinanceRechargeBills: vi.fn(),
fetchFinanceApplicationOptions: vi.fn(),
fetchFinanceRechargeApps: vi.fn(),
fetchFinanceRechargeOverview: vi.fn(),
fetchFinanceRechargeSummaries: vi.fn(),
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(),
listFinanceRechargeRegions: vi.fn(),
listFinanceWithdrawalApplications: vi.fn(),
listMyFinanceApplications: vi.fn(),
rejectFinanceApplication: vi.fn(),
rejectFinanceWithdrawalApplication: vi.fn(),
refreshFinanceGoogleRechargePaid: 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,
@ -25,21 +59,71 @@ vi.mock("./api.js", () => ({
canViewWithdrawalApplications: false,
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
user: { account: "admin", name: "admin" }
})),
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
grantFinanceCoinSellerRechargeOrder: vi.fn(),
listFinanceApplications: vi.fn(),
listFinanceCoinSellerRechargeOrders: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
listFinanceRechargeDetails: vi.fn(),
listFinanceWithdrawalApplications: vi.fn(),
listMyFinanceApplications: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
rejectFinanceApplication: vi.fn(),
rejectFinanceWithdrawalApplication: vi.fn(),
verifyFinanceCoinSellerRechargeOrder: vi.fn()
}));
};
beforeEach(() => {
vi.mocked(fetchFinanceApplicationOptions).mockResolvedValue({
apps: [{ appCode: "lalu", appName: "lalu" }],
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
walletIdentities: []
});
vi.mocked(fetchFinanceRechargeApps).mockResolvedValue([{ appCode: "lalu", appName: "Lalu" }]);
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
vi.mocked(listFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
vi.mocked(listMyFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
window.history.pushState(null, "", "/");
});
test("preserves the finance entry when the expired session asks the user to log in again", async () => {
vi.mocked(fetchFinanceSession).mockRejectedValueOnce(new Error("登录已失效"));
window.history.pushState(null, "", "/finance/?view=rechargeDetails");
render(
<ToastProvider>
<FinanceApp />
</ToastProvider>
);
expect(await screen.findByRole("link", { name: "重新登录" })).toHaveAttribute(
"href",
"/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails"
);
});
test("defaults recharge finance views to yesterday in China timezone", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 6, 8, 4));
vi.mocked(fetchFinanceSession).mockResolvedValue({
...defaultSession,
canCreateApplication: false,
canViewAppRechargeDetails: true,
permissions: ["finance-recharge-detail:view"]
});
render(
<ToastProvider>
<FinanceApp />
</ToastProvider>
);
const yesterdayButton = await screen.findByRole("button", { name: "昨天" });
expect(yesterdayButton).toHaveClass("is-on");
expect(screen.getByRole("button", { name: "今天" })).toBeInTheDocument();
await waitFor(() => expect(listFinanceRechargeDetails).toHaveBeenCalled());
const [query] = vi.mocked(listFinanceRechargeDetails).mock.calls.at(-1);
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
});
test("keeps create application dialog open when backdrop is clicked", async () => {

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,16 +114,19 @@ 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",
providerCurrencyCode: "PHP",
targetUserId: "10001",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeReceipt({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeOrder("order-1");
@ -132,13 +138,23 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
expect(calls[0][1]?.method).toBe("GET");
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges");
expect(calls[1][1]?.method).toBe("POST");
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ externalOrderNo: "MIFA-001", providerCode: "mifapay" });
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
targetUserId: "10001",
usdAmount: 10
});
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications");
expect(calls[2][1]?.method).toBe("POST");
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10
});
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");
@ -147,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,7 +2,9 @@ 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";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
@ -11,9 +13,10 @@ 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,
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
@ -21,6 +24,7 @@ import {
import {
buildCoinSellerRechargeReceiptPayload,
buildCoinSellerRechargeOrderPayload,
buildCoinSellerRechargeQuotePayload,
coinSellerRechargeChainLabel,
coinSellerRechargeGrantStatusLabel,
coinSellerRechargeProviderLabel,
@ -34,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,
@ -55,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 || [];
@ -62,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({
@ -69,16 +133,35 @@ 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,
chain: "",
providerAmount: "",
providerCode: value,
providerCurrencyCode: "",
}));
setFormError("");
setReceiptVerification(null);
@ -96,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) {
@ -113,8 +200,6 @@ export function FinanceCoinSellerRechargeOrderList({
const payload = buildCoinSellerRechargeReceiptPayload(form);
const validationMessage = validateCoinSellerRechargeOrderPayload({
...buildCoinSellerRechargeOrderPayload(form),
coinAmount: 1,
targetUserId: "verification-only",
});
if (validationMessage) {
setFormError(validationMessage);
@ -201,7 +286,17 @@ export function FinanceCoinSellerRechargeOrderList({
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
<Button disabled={!canCreate} startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={openCreate}>
{canConfigureExchangeRate ? (
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
金币汇率配置
</Button>
) : null}
<Button
disabled={!canCreate}
startIcon={<AddOutlined fontSize="small" />}
variant="contained"
onClick={openCreate}
>
创建币商充值
</Button>
</div>
@ -234,7 +329,10 @@ export function FinanceCoinSellerRechargeOrderList({
<td className="finance-order-cell">{item.id || "-"}</td>
<td>{appName(item.appCode, apps)}</td>
<td>
<Stacked primary={item.targetDisplayUserId || item.targetUserId} secondary={item.targetUserId} />
<Stacked
primary={item.targetDisplayUserId || item.targetUserId}
secondary={item.targetUserId}
/>
</td>
<td className="r num">
<Stacked
@ -243,10 +341,16 @@ export function FinanceCoinSellerRechargeOrderList({
/>
</td>
<td>
<Stacked primary={coinSellerRechargeProviderLabel(item.providerCode)} secondary={providerMeta(item)} />
<Stacked
primary={coinSellerRechargeProviderLabel(item.providerCode)}
secondary={providerMeta(item)}
/>
</td>
<td>
<Stacked primary={item.externalOrderNo || "-"} secondary={providerAmountText(item)} />
<Stacked
primary={item.externalOrderNo || "-"}
secondary={providerAmountText(item)}
/>
</td>
<td className="finance-remark-cell">{item.remark || "-"}</td>
<td>
@ -266,7 +370,8 @@ export function FinanceCoinSellerRechargeOrderList({
primary={item.operatorName || "-"}
secondary={[
item.verifiedByName && `校验:${item.verifiedByName}`,
item.grantedByName && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
item.grantedByName &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
]
.filter(Boolean)
.join(" / ")}
@ -277,7 +382,8 @@ export function FinanceCoinSellerRechargeOrderList({
primary={formatTime(item.createdAtMs)}
secondary={[
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
item.grantedAtMs && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
item.grantedAtMs &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
]
.filter(Boolean)
.join(" / ")}
@ -330,13 +436,21 @@ export function FinanceCoinSellerRechargeOrderList({
) : null}
{total > pageSize ? (
<div className="finance-pagination">
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
<Button
disabled={page <= 1 || loading}
variant="outlined"
onClick={() => onFiltersChange({ page: page - 1 })}
>
上一页
</Button>
<span>
{page} / {total}
</span>
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
<Button
disabled={!hasNextPage || loading}
variant="outlined"
onClick={() => onFiltersChange({ page: page + 1 })}
>
下一页
</Button>
</div>
@ -349,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>
);
}
@ -374,19 +501,45 @@ 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 fullWidth maxWidth={false} open={open} slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }} onClose={disabled ? undefined : onClose}>
<Dialog
fullWidth
maxWidth={false}
open={open}
slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }}
onClose={disabled ? undefined : onClose}
>
<DialogTitle>创建币商充值</DialogTitle>
<DialogContent className="finance-create-dialog">
<form className="finance-form finance-form--dialog" id="coin-seller-recharge-order-form" onSubmit={onSubmit}>
<form
className="finance-form finance-form--dialog"
id="coin-seller-recharge-order-form"
onSubmit={onSubmit}
>
{formError ? <Alert severity="error">{formError}</Alert> : null}
<div className={form.providerCode === "usdt" ? "finance-form-grid finance-form-grid--coin-seller-create" : "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"}>
<div
className={
form.providerCode === "usdt"
? "finance-form-grid finance-form-grid--coin-seller-create"
: "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"
}
>
{apps.length ? (
<TextField disabled={disabled} label="APP" required select value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)}>
<TextField
disabled={disabled}
label="APP"
required
select
value={form.appCode}
onChange={(event) => onChange("appCode", event.target.value)}
>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
@ -394,9 +547,21 @@ function CreateDialog({
))}
</TextField>
) : (
<TextField disabled={disabled} label="APP" required value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)} />
<TextField
disabled={disabled}
label="APP"
required
value={form.appCode}
onChange={(event) => onChange("appCode", event.target.value)}
/>
)}
<TextField disabled={disabled} label="目标用户 ID" required value={form.targetUserId} onChange={(event) => onChange("targetUserId", event.target.value)} />
<TextField
disabled={disabled}
label="目标用户 ID"
required
value={form.targetUserId}
onChange={(event) => onChange("targetUserId", event.target.value)}
/>
<TextField
disabled={disabled}
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
@ -407,17 +572,32 @@ 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} label="支付渠道" required select value={form.providerCode} onChange={(event) => onChange("providerCode", event.target.value)}>
<TextField
disabled={disabled}
label="支付渠道"
required
select
value={form.providerCode}
onChange={(event) => onChange("providerCode", event.target.value)}
>
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
@ -425,7 +605,14 @@ function CreateDialog({
))}
</TextField>
{form.providerCode === "usdt" ? (
<TextField disabled={disabled} label="USDT 链" required select value={form.chain} onChange={(event) => onChange("chain", event.target.value)}>
<TextField
disabled={disabled}
label="USDT 链"
required
select
value={form.chain}
onChange={(event) => onChange("chain", event.target.value)}
>
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
@ -433,9 +620,45 @@ function CreateDialog({
))}
</TextField>
) : null}
{form.providerCode !== "usdt" ? (
<>
<Autocomplete
disabled={disabled}
getOptionLabel={currencyOptionLabel}
isOptionEqualToValue={(option, value) => option === value}
noOptionsText="无匹配币种"
options={providerCurrencyOptions(form.providerCode)}
renderInput={(params) => (
<TextField {...params} label="币种" required />
)}
value={form.providerCurrencyCode || null}
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: "0.01" }}
label="币种金额"
required
type="number"
value={form.providerAmount}
onChange={(event) => onChange("providerAmount", event.target.value)}
/>
</>
) : null}
<div className="finance-receipt-field">
<TextField disabled={disabled} label="三方订单号" required value={form.externalOrderNo} onChange={(event) => onChange("externalOrderNo", event.target.value)} />
<Button disabled={disabled || !onVerifyReceipt} startIcon={<FactCheckOutlined fontSize="small" />} variant="outlined" onClick={onVerifyReceipt}>
<TextField
disabled={disabled}
label="三方订单号"
required
value={form.externalOrderNo}
onChange={(event) => onChange("externalOrderNo", event.target.value)}
/>
<Button
disabled={disabled || !onVerifyReceipt}
startIcon={<FactCheckOutlined fontSize="small" />}
variant="outlined"
onClick={onVerifyReceipt}
>
校验
</Button>
</div>
@ -466,6 +689,31 @@ function isZeroCoinMakeupOrder(order) {
return Number(order?.coinAmount || 0) === 0;
}
function providerCurrencyOptions(providerCode) {
return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || [];
}
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}`,
@ -486,7 +734,11 @@ function ReceiptVerificationBanner({ verification }) {
}
function StatusBadge({ status }) {
return <span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>{coinSellerRechargeStatusLabel(status)}</span>;
return (
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
{coinSellerRechargeStatusLabel(status)}
</span>
);
}
function Stacked({ primary, secondary }) {
@ -523,7 +775,9 @@ function providerMeta(item) {
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "v5pay") {
return [item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-";
return (
[item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-"
);
}
return "-";
}
@ -531,8 +785,12 @@ function providerMeta(item) {
function walletMeta(item) {
return [
item.walletAssetType,
item.walletAmountDelta === null || item.walletAmountDelta === undefined ? "" : `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined ? "" : `余额 ${formatAmount(item.walletBalanceAfter)}`,
item.walletAmountDelta === null || item.walletAmountDelta === undefined
? ""
: `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined
? ""
: `余额 ${formatAmount(item.walletBalanceAfter)}`,
]
.filter(Boolean)
.join(" / ");

View File

@ -8,27 +8,45 @@ 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: "USD",
providerAmountMinor: 1000,
currencyCode: "PHP",
providerAmountMinor: 4800,
providerOrderId: "V5-001",
status: "paid",
verified: true,
}));
render(<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />);
render(
<FinanceCoinSellerRechargeOrderList
{...baseProps()}
onCreate={onCreate}
onQuote={onQuote}
onVerifyReceipt={onVerifyReceipt}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
fireEvent.click(screen.getByRole("option", { name: "V5Pay" }));
expect(screen.queryByRole("textbox", { name: "三方国家码" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "三方币种" })).not.toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "币种" })).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "币种金额" })).toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "支付类型" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "订单日期" })).not.toBeInTheDocument();
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" } });
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
@ -39,16 +57,20 @@ test("coin seller recharge order create dialog verifies receipt before create",
expect(onVerifyReceipt).toHaveBeenCalledWith({
appCode: "lalu",
externalOrderNo: "V5-001",
providerAmountMinor: 4800,
providerCode: "v5pay",
providerCurrencyCode: "PHP",
usdAmount: 10,
});
fireEvent.click(screen.getByRole("button", { name: "创建" }));
await waitFor(() =>
expect(onCreate).toHaveBeenCalledWith({
appCode: "lalu",
coinAmount: 0,
externalOrderNo: "V5-001",
isMakeup: false,
providerAmountMinor: 4800,
providerCode: "v5pay",
providerCurrencyCode: "PHP",
remark: "补单,不发金币",
targetUserId: "10001",
usdAmount: 10,
@ -56,10 +78,57 @@ 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();
vi.stubGlobal("confirm", vi.fn(() => true));
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
render(
<FinanceCoinSellerRechargeOrderList
@ -100,7 +169,9 @@ test("zero coin seller recharge order uses makeup completion action", () => {
<FinanceCoinSellerRechargeOrderList
{...baseProps({
orders: {
items: [orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" })],
items: [
orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" }),
],
page: 1,
pageSize: 50,
total: 1,
@ -121,6 +192,7 @@ function baseProps(overrides = {}) {
actionLoading: "",
apps: [{ appCode: "lalu", appName: "lalu" }],
canCreate: true,
canConfigureExchangeRate: false,
canGrant: true,
canVerify: true,
error: "",
@ -135,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

@ -467,15 +467,8 @@ function coinText(value) {
}
function billAmountText(item) {
if (item.billAmountText) {
return item.billAmountText;
}
const amount = hasNumber(item.billAmountMinor)
? item.billAmountMinor
: hasPositiveNumber(item.providerAmountMinor)
? item.providerAmountMinor
: item.usdMinorAmount;
return formatUsdMinor(amount, item.currencyCode || "USD");
// USDT currencyCode/providerAmountMinor
return formatUsdMinor(item.usdMinorAmount, "USDT");
}
function userPaidText(item) {

View File

@ -76,10 +76,41 @@ test("renders app recharge coin columns and values when coin currency is selecte
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("tx-google")).toBeInTheDocument();
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
expect(screen.getAllByText("SAR 12.99")).toHaveLength(2);
expect(screen.getByText("USDT 9.99")).toBeInTheDocument();
expect(screen.getByText("SAR 12.99")).toBeInTheDocument();
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
});
test("keeps bill amount in USDT while rendering third-party paid amount in local currency", () => {
render(
<FinanceRechargeDetailList
{...baseProps}
bills={{
...baseProps.bills,
items: [
{
...rechargeBillFixture(),
currencyCode: "EGP",
id: "tx-mifapay",
providerAmountMinor: 472600,
rechargeType: "third_party_recharge",
transactionId: "tx-mifapay",
usdMinorAmount: 10000,
userPaidAmountMicro: 4726000000,
userPaidCurrencyCode: "EGP",
userPaidText: "",
},
],
total: 1,
}}
/>,
);
expect(screen.getByText("USDT 100.00")).toBeInTheDocument();
expect(screen.getByText("EGP 4,726.00")).toBeInTheDocument();
expect(screen.queryByText("EGP 100.00")).not.toBeInTheDocument();
});
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);

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",
@ -70,6 +71,11 @@ export const COIN_SELLER_RECHARGE_CHAINS = [
["BSC", "BSC"],
];
export const COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES = {
mifapay: ["PHP", "INR", "IDR", "VND", "SAR", "AED", "BHD", "QAR", "OMR", "KWD", "BDT", "PKR", "EGP"],
v5pay: ["PHP", "SAR", "EGP", "AED", "IDR", "INR", "PKR", "TRY", "MXN"],
};
export const COIN_SELLER_RECHARGE_STATUS = [
["pending", "待处理"],
["verified", "已校验"],
@ -106,7 +112,10 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
chain: "",
coinAmount: "",
externalOrderNo: "",
isMakeup: false,
providerAmount: "",
providerCode: "mifapay",
providerCurrencyCode: "",
remark: "",
targetUserId: "",
usdAmount: "",

View File

@ -129,12 +129,16 @@ export function normalizeWithdrawalApplicationPage(data = {}) {
export function buildCoinSellerRechargeOrderPayload(form) {
const providerCode = stringValue(form.providerCode);
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
const providerAmountMinor = providerAmountMinorFromForm(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 : "",
remark: stringValue(form.remark),
targetUserId: stringValue(form.targetUserId),
usdAmount: numberValue(form.usdAmount),
@ -145,12 +149,16 @@ export function buildCoinSellerRechargeOrderPayload(form) {
export function buildCoinSellerRechargeReceiptPayload(form) {
const providerCode = stringValue(form.providerCode);
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
const providerAmountMinor = providerAmountMinorFromForm(form);
return Object.fromEntries(
Object.entries({
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
externalOrderNo: stringValue(form.externalOrderNo),
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
providerCode,
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
usdAmount: numberValue(form.usdAmount),
}).filter(([, value]) => value !== ""),
);
@ -166,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 "请选择支付渠道";
}
@ -178,12 +183,76 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
if (payload.providerCode === "usdt" && !payload.chain) {
return "请选择 USDT 链";
}
if (requiresThirdPartyProviderAmount(payload.providerCode)) {
if (!payload.providerCurrencyCode) {
return "请填写币种";
}
if (!Number.isInteger(payload.providerAmountMinor) || payload.providerAmountMinor <= 0) {
return "请填写大于 0 的币种金额";
}
}
if (payload.remark && payload.remark.length > 512) {
return "备注不能超过 512 字";
}
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),
@ -642,6 +711,26 @@ function numberOrNull(value) {
return Number.isFinite(nextValue) ? nextValue : null;
}
function providerMoneyToMinor(value) {
const number = numberValue(value);
if (!Number.isFinite(number)) {
return Number.NaN;
}
return Math.round(number * 100);
}
function providerAmountMinorFromForm(form) {
// 表单输入是常规金额,已构建 payload 重用时则直接沿用 minor避免校验请求二次放大金额。
if (stringValue(form.providerAmount) !== "") {
return providerMoneyToMinor(form.providerAmount);
}
return numberValue(form.providerAmountMinor);
}
function requiresThirdPartyProviderAmount(providerCode) {
return ["mifapay", "v5pay"].includes(stringValue(providerCode).toLowerCase());
}
function stringValue(value) {
return String(value ?? "").trim();
}

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";
@ -170,7 +173,9 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: " lalu ",
coinAmount: "100000",
externalOrderNo: " MIFA-001 ",
providerAmount: "1250.5",
providerCode: "mifapay",
providerCurrencyCode: " php ",
remark: " 人工补单 ",
targetUserId: " 10001 ",
usdAmount: "10.5",
@ -178,9 +183,11 @@ 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",
remark: "人工补单",
targetUserId: "10001",
usdAmount: 10.5,
@ -194,16 +201,88 @@ test("builds validates and normalizes coin seller recharge orders", () => {
).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10.5,
});
expect(validateCoinSellerRechargeOrderPayload(payload)).toBe("");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe("");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCurrencyCode: "" })).toBe("请填写币种");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe(
"请填写大于 0 的币种金额",
);
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
"请选择 USDT 链",
);
const boundaryPayload = buildCoinSellerRechargeOrderPayload({
...payload,
externalOrderNo: "V5-001",
providerAmount: "0.01",
providerCode: "v5pay",
providerCurrencyCode: "try",
});
expect(boundaryPayload).toMatchObject({
externalOrderNo: "V5-001",
providerAmountMinor: 1,
providerCode: "v5pay",
providerCurrencyCode: "TRY",
});
expect(validateCoinSellerRechargeOrderPayload(boundaryPayload)).toBe("");
expect(
validateCoinSellerRechargeOrderPayload(
buildCoinSellerRechargeOrderPayload({
...payload,
providerAmount: "abc",
providerCode: "v5pay",
providerCurrencyCode: "TRY",
}),
),
).toBe("请填写大于 0 的币种金额");
expect(
validateCoinSellerRechargeOrderPayload(
buildCoinSellerRechargeOrderPayload({
...payload,
providerAmount: "",
providerAmountMinor: undefined,
providerCode: "v5pay",
providerCurrencyCode: "TRY",
}),
),
).toBe("请填写大于 0 的币种金额");
expect(
buildCoinSellerRechargeOrderPayload({
...payload,
chain: "tron",
providerAmount: "48",
providerCode: "usdt",
providerCurrencyCode: "PHP",
}),
).toEqual({
appCode: "lalu",
chain: "TRON",
externalOrderNo: "MIFA-001",
isMakeup: false,
providerCode: "usdt",
remark: "人工补单",
targetUserId: "10001",
usdAmount: 10.5,
});
expect(
buildCoinSellerRechargeReceiptPayload({
...payload,
chain: "bsc",
providerAmount: "48",
providerCode: "usdt",
providerCurrencyCode: "PHP",
}),
).toEqual({
appCode: "lalu",
chain: "BSC",
externalOrderNo: "MIFA-001",
providerCode: "usdt",
usdAmount: 10.5,
});
const page = normalizeCoinSellerRechargeOrderPage({
items: [
@ -243,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;

12
ops-center/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 运营中心</title>
</head>
<body>
<div id="ops-center-root"></div>
<script type="module" src="/ops-center/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,165 @@
import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { AppsView } from "./components/AppsView.jsx";
import { ConfigsView } from "./components/ConfigsView.jsx";
import { DrawsView } from "./components/DrawsView.jsx";
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx";
const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
{ icon: AppsOutlined, key: "apps", label: "应用列表" }
];
const initialDashboard = {
apps: [],
appSummaries: [],
luckyGifts: [],
poolBalances: [],
summary: {}
};
export function OpsCenterApp() {
const [activeView, setActiveView] = useState("overview");
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null);
const [savingConfig, setSavingConfig] = useState(false);
const { showToast } = useToast();
const loadDashboard = useCallback(async () => {
setState((current) => ({ ...current, error: "", loading: true }));
try {
const data = await fetchOpsDashboard();
setState({ data, error: "", loaded: true, loading: false });
} catch (error) {
//
setState((current) => ({
data: current.data || initialDashboard,
error: error.message || "运营接口暂不可用",
loaded: current.loaded,
loading: false
}));
}
}, []);
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
const { data } = state;
const appOptions = useMemo(
() => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
[data.apps]
);
// /
const poolIDsByApp = useMemo(() => {
const byApp = new Map();
const add = (appCode, poolID) => {
if (!appCode || !poolID) {
return;
}
if (!byApp.has(appCode)) {
byApp.set(appCode, new Set());
}
byApp.get(appCode).add(poolID);
};
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
}, [data.luckyGifts, data.poolBalances]);
const openCreateConfig = useCallback(() => {
// 稿
// ""
const template = data.luckyGifts[0];
if (!template) {
showToast("暂无可添加配置的应用", "warning");
return;
}
setDialog({
config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 },
mode: "create"
});
}, [data.luckyGifts, showToast]);
const openEditConfig = useCallback((config) => {
setDialog({ config: structuredClone(config), mode: "edit" });
}, []);
const handleSaveConfig = useCallback(
async (config) => {
setSavingConfig(true);
try {
await saveLuckyGiftConfig(config);
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
setDialog(null);
await loadDashboard();
} catch (error) {
//
showToast(error.message || "保存幸运礼物配置失败", "error");
} finally {
setSavingConfig(false);
}
},
[loadDashboard, showToast]
);
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
return (
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
<div className="ops-page">
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
刷新
</Button>
</PageHead>
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
{state.error && state.loaded ? (
<div className="ops-alert" role="alert">
{state.error}
</div>
) : null}
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}>
{activeView === "overview" ? <OverviewView data={data} /> : null}
{activeView === "configs" ? (
<ConfigsView
apps={data.apps}
luckyGifts={data.luckyGifts}
saving={savingConfig}
onCreate={openCreateConfig}
onEdit={openEditConfig}
/>
) : null}
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null}
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
</DataState>
{dialog ? (
<LuckyGiftConfigDialog
appOptions={appOptions}
config={dialog.config}
mode={dialog.mode}
saving={savingConfig}
onClose={() => setDialog(null)}
onSubmit={handleSaveConfig}
/>
) : null}
</div>
</OpsShell>
);
}

View File

@ -0,0 +1,176 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default",
fetchLuckyGiftDraws: vi.fn(),
fetchOpsDashboard: vi.fn(),
saveLuckyGiftConfig: vi.fn()
}));
beforeEach(() => {
vi.clearAllMocks();
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
fetchLuckyGiftDraws.mockResolvedValue({
items: [
{
app_code: "lalu",
created_at_ms: 1767000000000,
draw_id: "draw-1",
effective_reward_coins: 100,
external_user_id: "ext-1",
gift_id: "gift-9",
multiplier_ppm: 2_000_000,
pool_id: "super_lucky",
reward_status: "granted",
rule_version: 2
}
],
page: 1,
pageSize: 20,
total: 1
});
fetchOpsDashboard.mockResolvedValue({
apps: [
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }
],
appSummaries: [
{
actual_rtp_ppm: 900000,
app_code: "lalu",
failed_draws: 0,
granted_draws: 3,
pending_draws: 0,
total_draws: 3,
total_reward_coins: 900,
total_spent_coins: 1000,
unique_rooms: 2,
unique_users: 2
}
],
luckyGifts: [
{ app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
{ app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
{ app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }
],
poolBalances: [
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 },
{ app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
],
summary: {
activeApps: 2,
configuredPools: 2,
enabledPools: 1,
failedDraws: 0,
grantedDraws: 3,
pendingDraws: 0,
poolAvailableTotal: 38311517,
poolBalanceTotal: 38941517,
poolReserveTotal: 630000,
totalDraws: 3,
totalRewardCoins: 900,
totalSpentCoins: 1000
}
});
});
function renderApp() {
return render(
<ToastProvider>
<OpsCenterApp />
</ToastProvider>
);
}
test("renders overview with aggregated kpis and pool balances", async () => {
renderApp();
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
});
expect(await screen.findByText("运营应用")).toBeTruthy();
// KPI KPI
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
expect(screen.getByText("奖池实时水位")).toBeTruthy();
expect(screen.getByText("已入账")).toBeTruthy();
expect(screen.getByText("默认水位")).toBeTruthy();
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
});
test("lists every pool config including published non-default pools", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// lalu lucky / super_lucky
expect(await screen.findByText("lucky")).toBeTruthy();
expect(screen.getByText("super_lucky")).toBeTruthy();
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getByText("v2")).toBeTruthy();
expect(screen.getByText("已启用")).toBeTruthy();
expect(screen.getByText("已停用")).toBeTruthy();
expect(screen.getByText("默认草稿")).toBeTruthy();
});
test("opens config dialog from a published pool row and saves a new version", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
await screen.findByText("lucky");
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
await waitFor(() => {
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
expect.objectContaining({
app_code: "lalu",
pool_id: "lucky",
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
target_rtp_percent: 88
})
);
});
});
test("loads draw records for the first app with server pagination", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
await waitFor(() => {
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" })
);
});
expect(await screen.findByText("ext-1")).toBeTruthy();
expect(screen.getByText("已发放")).toBeTruthy();
expect(screen.getByText("2x")).toBeTruthy();
});
test("shows app registry and fixed integrations in apps view", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
expect(await screen.findByText("Lalu")).toBeTruthy();
expect(screen.getByText("主数据")).toBeTruthy();
expect(screen.getByText("外部接入")).toBeTruthy();
});

264
ops-center/src/api.js Normal file
View File

@ -0,0 +1,264 @@
import { getAccessToken } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default";
export async function fetchOpsDashboard(query = {}) {
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
// lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。
const appsPayload = await opsRequest("/apps", { query });
const apps = normalizeItems(appsPayload);
const appCodes = uniqueAppCodes(apps);
const perApp = await Promise.all(
appCodes.map(async (appCode) => {
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
fetchAppLuckyGiftConfigs(appCode),
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } })
]);
return { appCode, drawSummary, luckyGifts, poolBalances };
})
);
return normalizeDashboard({ apps, perApp });
}
async function fetchAppLuckyGiftConfigs(appCode) {
// 必须用复数 /configs它返回该应用每个奖池的最新已发布规则版本。
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } }))
.map((config) => normalizeLuckyGiftConfig(config, appCode))
.filter((config) => config.app_code);
if (published.length) {
return published;
}
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
// 草稿的默认参数RTP、回收率、结算窗口以服务端 DefaultRuleConfig 为准,不在前端伪造。
const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode);
return draft.app_code ? [draft] : [];
}
export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) {
const query = {
app_code: appCode,
page,
page_size: pageSize,
pool_id: poolId,
status
};
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID纯数字走 user_id 精确匹配,
// 其余走 external_user_id避免运营要先分辨用户来源再选字段。
const trimmedUserID = String(userId || "").trim();
if (/^\d+$/.test(trimmedUserID)) {
query.user_id = trimmedUserID;
} else if (trimmedUserID) {
query.external_user_id = trimmedUserID;
}
const payload = await opsRequest("/lucky-gifts/draws", { query });
return {
items: normalizeItems(payload),
page: numberValue(payload.page) || page,
pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
total: numberValue(payload.total)
};
}
export async function saveLuckyGiftConfig(config = {}) {
const payload = luckyGiftConfigPayload(config);
return opsRequest("/lucky-gifts/config", {
body: payload,
method: "PUT",
query: {
app_code: payload.app_code,
pool_id: payload.pool_id
}
});
}
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
const response = await fetch(buildOpsUrl(path, query), {
body: body === undefined ? undefined : JSON.stringify(body),
credentials: "include",
headers: buildHeaders(body),
method
});
const payload = await readPayload(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "运营接口请求失败");
}
return payload.data ?? {};
}
export function buildOpsUrl(path, query = {}) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
const poolBalances = perApp
.flatMap((entry) => entry.poolBalances || [])
.filter((item) => item?.app_code || item?.appCode);
const appSummaries = perApp
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
.filter((summary) => summary.app_code);
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
const summary = {
activeApps: apps.length,
configuredPools: publishedConfigs.length,
enabledPools: publishedConfigs.filter((config) => config.enabled).length,
failedDraws: sumBy(appSummaries, "failed_draws"),
grantedDraws: sumBy(appSummaries, "granted_draws"),
pendingDraws: sumBy(appSummaries, "pending_draws"),
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
totalDraws: sumBy(appSummaries, "total_draws"),
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
totalSpentCoins: sumBy(appSummaries, "total_spent_coins")
};
return { apps, appSummaries, luckyGifts, poolBalances, summary };
}
function normalizeDrawSummary(summary = {}, appCode = "") {
return {
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
app_code: String(appCode || summary.app_code || "").trim().toLowerCase(),
failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
total_draws: numberValue(summary.total_draws ?? summary.totalDraws),
total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers)
};
}
function uniqueAppCodes(apps) {
const seen = new Set();
const codes = [];
apps.forEach((item) => {
const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase();
if (!code || seen.has(code)) {
return;
}
seen.add(code);
codes.push(code);
});
return codes;
}
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase();
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
return {
...config,
app_code: appCode,
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
is_default: ruleVersion <= 0,
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
rule_version: ruleVersion
};
}
function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase();
const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
return {
app_code: appCode,
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS),
enabled: Boolean(config.enabled),
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
pool_id: poolID,
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
stages: normalizeStages(config.stages),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent)
};
}
function buildHeaders(body) {
const headers = {};
const token = getAccessToken();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
async function readPayload(response) {
const text = await response.text();
if (!text) {
return { code: response.ok ? 0 : response.status, data: {} };
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, data: text, message: text };
}
}
function normalizeItems(value) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.items)) {
return value.items;
}
if (Array.isArray(value?.list)) {
return value.list;
}
return [];
}
function sumBy(items, key, fallbackKey) {
return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0);
}
function numberValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizeStages(stages) {
if (!Array.isArray(stages)) {
return [];
}
return stages.map((stage) => ({
stage: stage.stage || stage.Stage || "",
tiers: Array.isArray(stage.tiers)
? stage.tiers.map((tier) => ({
enabled: tier.enabled !== false,
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
multiplier: numberValue(tier.multiplier),
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
tier_id: tier.tier_id || tier.tierId || ""
}))
: []
}));
}

146
ops-center/src/api.test.js Normal file
View File

@ -0,0 +1,146 @@
import { afterEach, expect, test, vi } from "vitest";
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
afterEach(() => {
vi.restoreAllMocks();
});
function jsonResponse(data) {
return new Response(JSON.stringify({ code: 0, data }), {
headers: { "Content-Type": "application/json" },
status: 200
});
}
test("builds ops api urls under the dedicated prefix", () => {
const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });
expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
});
test("loads every pool config per app through the plural configs endpoint", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
const text = String(url);
calls.push(text);
if (text.endsWith("/apps")) {
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
}
if (text.includes("/lucky-gifts/configs?")) {
// lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。
return jsonResponse([
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }
]);
}
if (text.includes("/lucky-gifts/pools?")) {
return jsonResponse({
items: [
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 },
{ app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
]
});
}
if (text.includes("/lucky-gifts/summary?")) {
return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 });
}
return jsonResponse({ items: [] });
});
const data = await fetchOpsDashboard();
expect(calls).toEqual([
"http://localhost:3000/api/v1/admin/ops-center/apps",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu"
]);
expect(data.luckyGifts).toEqual([
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 })
]);
expect(data.summary).toMatchObject({
activeApps: 1,
configuredPools: 2,
enabledPools: 1,
poolBalanceTotal: 38941517,
totalDraws: 10
});
});
test("falls back to the server default draft when an app has no published config", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
const text = String(url);
calls.push(text);
if (text.endsWith("/apps")) {
return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] });
}
if (text.includes("/lucky-gifts/configs?")) {
return jsonResponse([]);
}
if (text.includes("/lucky-gifts/config?")) {
return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 });
}
return jsonResponse({ items: [] });
});
const data = await fetchOpsDashboard();
expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
expect(data.luckyGifts).toEqual([
expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 })
]);
});
test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
calls.push(String(url));
return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 });
});
const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" });
await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" });
const numericParams = new URL(calls[0]).searchParams;
expect(numericParams.get("app_code")).toBe("lalu");
expect(numericParams.get("pool_id")).toBe("lucky");
expect(numericParams.get("user_id")).toBe("10001");
expect(numericParams.get("external_user_id")).toBeNull();
const externalParams = new URL(calls[1]).searchParams;
expect(externalParams.get("external_user_id")).toBe("ext-abc");
expect(externalParams.get("user_id")).toBeNull();
expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 });
});
test("saves lucky gift config through admin ops route", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: init.body, method: init.method, url: String(url) });
return jsonResponse({ app_code: "aslan", pool_id: "default" });
});
await saveLuckyGiftConfig({
app_code: "aslan",
pool_id: "default",
enabled: true,
target_rtp_percent: 95,
pool_rate_percent: 96,
settlement_window_wager: 1_000_000,
control_band_percent: 1,
gift_price_reference: 100,
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }]
});
expect(calls[0].method).toBe("PUT");
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default");
expect(JSON.parse(calls[0].body)).toMatchObject({
app_code: "aslan",
enabled: true,
pool_id: "default",
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }],
target_rtp_percent: 95
});
});

View File

@ -0,0 +1,97 @@
import { useMemo, useState } from "react";
import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function AppsView({ apps }) {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
//
const items = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return apps.filter((item) => {
const code = String(item.app_code ?? item.appCode ?? "").toLowerCase();
const name = String(item.app_name ?? item.appName ?? "").toLowerCase();
if (normalizedKeyword && !code.includes(normalizedKeyword) && !name.includes(normalizedKeyword)) {
return false;
}
if (status && String(item.status || "").toLowerCase() !== status) {
return false;
}
return true;
});
}, [apps, keyword, status]);
return (
<div className="ops-view">
<AdminListToolbar
filters={
<>
<AdminSearchBox label="应用" placeholder="应用编码 / 名称" value={keyword} onChange={setKeyword} />
<AdminFilterSelect
label="状态"
options={[
["", "全部状态"],
["active", "已启用"],
["disabled", "已停用"]
]}
value={status}
onChange={setStatus}
/>
<AdminFilterResetButton
disabled={!keyword && !status}
onClick={() => {
setKeyword("");
setStatus("");
}}
/>
</>
}
/>
<DataTable
columns={appColumns}
emptyLabel="暂无应用"
items={items}
minWidth="880px"
rowKey={(item) => item.app_code ?? item.appCode}
title="应用列表"
total={items.length}
/>
</div>
);
}
const appColumns = [
{ key: "app_code", label: "应用编码", render: (item) => item.app_code ?? item.appCode, width: "minmax(100px, 0.8fr)" },
{ key: "app_name", label: "应用名称", render: (item) => item.app_name ?? item.appName },
{ key: "platform", label: "平台", render: (item) => item.platform || "-", width: "minmax(80px, 0.6fr)" },
{ key: "package_name", label: "包名", render: (item) => item.package_name ?? item.packageName ?? "-", width: "minmax(180px, 1.4fr)" },
{
key: "status",
label: "状态",
render: (item) =>
String(item.status || "").toLowerCase() === "active" ? (
<OpsStatusBadge label="已启用" tone="succeeded" />
) : (
<OpsStatusBadge label={item.status || "-"} />
),
width: "minmax(112px, 0.6fr)"
},
{
key: "source",
label: "来源",
// fixed admin-server Appyumi/aslan
// app registry /
render: (item) =>
item.source === "fixed" ? <OpsStatusBadge label="外部接入" tone="warning" /> : <OpsStatusBadge label="主数据" tone="succeeded" />,
width: "minmax(124px, 0.7fr)"
},
{
key: "updated_at_ms",
label: "更新时间",
render: (item) => (item.source === "fixed" ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />),
width: "minmax(150px, 1fr)"
}
];

View File

@ -0,0 +1,90 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import { useMemo, useState } from "react";
import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { formatNumber, formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
const [appFilter, setAppFilter] = useState("");
const appOptions = useMemo(
() => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
[apps]
);
// dashboard
const items = useMemo(
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
[appFilter, luckyGifts]
);
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
return (
<div className="ops-view">
<AdminListToolbar
actions={
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
添加配置
</Button>
}
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
/>
<DataTable
columns={columns}
emptyLabel="暂无幸运礼物配置"
items={items}
minWidth="1080px"
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
title="幸运礼物配置"
total={items.length}
/>
</div>
);
}
function buildColumns({ onEdit, saving }) {
return [
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
{
key: "rule_version",
label: "版本",
render: (item) => (item.is_default ? "-" : `v${item.rule_version}`),
width: "minmax(72px, 0.5fr)"
},
{
key: "enabled",
label: "状态",
render: (item) => {
// 稿线""
if (item.is_default) {
return <OpsStatusBadge label="默认草稿" tone="warning" />;
}
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />;
},
width: "minmax(128px, 0.8fr)"
},
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
{ key: "pool_rate_percent", label: "奖池回收", render: (item) => formatPercent(item.pool_rate_percent) },
{ key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) },
{ key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) },
{
key: "created_at_ms",
label: "发布时间",
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
width: "minmax(176px, 1.1fr)"
},
{
key: "actions",
label: "操作",
render: (item) => (
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
{item.is_default ? "发布配置" : "新增版本"}
</Button>
)
}
];
}

View File

@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { fetchLuckyGiftDraws } from "../api.js";
import { formatMultiplierFromPPM, formatNumber } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
const PAGE_SIZE = 20;
const initialFilters = { poolId: "", status: "", userId: "" };
export function DrawsView({ apps, defaultAppCode, poolIDsByApp }) {
//
const [appCode, setAppCode] = useState(defaultAppCode);
const [filters, setFilters] = useState(initialFilters);
const [page, setPage] = useState(1);
const [state, setState] = useState({ error: "", items: [], loading: false, total: 0 });
useEffect(() => {
// dashboard defaultAppCode
setAppCode((current) => current || defaultAppCode);
}, [defaultAppCode]);
useEffect(() => {
if (!appCode) {
return undefined;
}
let cancelled = false;
setState((current) => ({ ...current, error: "", loading: true }));
fetchLuckyGiftDraws({ appCode, page, pageSize: PAGE_SIZE, poolId: filters.poolId, status: filters.status, userId: filters.userId })
.then((result) => {
if (cancelled) {
return;
}
setState((current) => ({
error: "",
// page 1
items: page > 1 ? [...current.items, ...result.items] : result.items,
loading: false,
total: result.total
}));
})
.catch((error) => {
if (cancelled) {
return;
}
setState((current) => ({ ...current, error: error.message || "获取抽奖记录失败", loading: false }));
});
return () => {
cancelled = true;
};
}, [appCode, filters, page]);
const changeAppCode = useCallback((nextAppCode) => {
setAppCode(nextAppCode);
//
setFilters(initialFilters);
setPage(1);
}, []);
const changeFilter = useCallback((key, value) => {
setFilters((current) => ({ ...current, [key]: value }));
setPage(1);
}, []);
const resetFilters = useCallback(() => {
setFilters(initialFilters);
setPage(1);
}, []);
const appOptions = useMemo(() => apps.map((item) => [item.app_code ?? item.appCode, item.app_code ?? item.appCode]), [apps]);
const poolOptions = useMemo(() => (poolIDsByApp.get(appCode) || []).map((poolID) => [poolID, poolID]), [appCode, poolIDsByApp]);
const hasActiveFilters = Boolean(filters.poolId || filters.status || filters.userId);
return (
<div className="ops-view">
<AdminListToolbar
filters={
<>
<AdminFilterSelect label="应用" options={appOptions} value={appCode || ""} onChange={changeAppCode} />
<AdminFilterSelect
label="奖池"
options={[["", "全部奖池"], ...poolOptions]}
value={filters.poolId}
onChange={(value) => changeFilter("poolId", value)}
/>
<AdminFilterSelect
label="发放状态"
options={[["", "全部状态"], ...drawStatusOptions]}
value={filters.status}
onChange={(value) => changeFilter("status", value)}
/>
<AdminSearchBox
label="用户"
placeholder="用户 ID / 外部用户 ID"
value={filters.userId}
onChange={(value) => changeFilter("userId", value)}
/>
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
</>
}
/>
{state.error ? <div className="ops-alert" role="alert">{state.error}</div> : null}
<DataTable
columns={drawColumns}
emptyLabel={state.loading ? "加载中..." : "暂无抽奖记录"}
items={state.items}
minWidth="1080px"
pagination={{ onPageChange: setPage, page, total: state.total }}
rowKey={(item) => item.draw_id ?? item.drawId}
title="抽奖记录"
total={state.total}
/>
</div>
);
}
const drawColumns = [
{
key: "created_at_ms",
label: "抽奖时间",
render: (item) => <TimeText value={item.created_at_ms ?? item.createdAtMs} />,
width: "minmax(150px, 1fr)"
},
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(80px, 0.6fr)" },
{
key: "user",
label: "用户",
// external_user_id user_id
render: (item) => item.external_user_id || item.externalUserId || item.user_id || item.userId || "-",
width: "minmax(120px, 1fr)"
},
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(100px, 0.8fr)" },
{ key: "gift_id", label: "礼物", render: (item) => item.gift_id ?? item.giftId, width: "minmax(100px, 0.8fr)" },
{
key: "multiplier_ppm",
label: "命中倍率",
render: (item) => formatMultiplierFromPPM(item.multiplier_ppm ?? item.multiplierPpm),
width: "minmax(88px, 0.6fr)"
},
{ key: "effective_reward_coins", label: "中奖金币", render: (item) => formatNumber(item.effective_reward_coins ?? item.effectiveRewardCoins) },
{
key: "reward_status",
label: "发放状态",
render: (item) => renderRewardStatus(item.reward_status ?? item.rewardStatus),
width: "minmax(124px, 0.8fr)"
},
{
key: "rule_version",
label: "规则版本",
render: (item) => `v${item.rule_version ?? item.ruleVersion ?? "-"}`,
width: "minmax(80px, 0.5fr)"
}
];
function renderRewardStatus(status) {
if (status === "granted") {
return <OpsStatusBadge label="已发放" tone="succeeded" />;
}
if (status === "failed") {
return <OpsStatusBadge label="发放失败" tone="danger" />;
}
if (status === "pending") {
return <OpsStatusBadge label="发放中" tone="warning" />;
}
return <OpsStatusBadge label={status || "-"} />;
}

View File

@ -0,0 +1,294 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import Checkbox from "@mui/material/Checkbox";
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 FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import {
configToForm,
correctAllStageProbabilities,
formToConfig,
nextStageMultiplier,
stageSummary
} from "../configForm.js";
import { formatNumber, formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config));
const [activeStage, setActiveStage] = useState("novice");
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage;
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
const updateField = (field, value) => {
setForm((current) => {
const next = { ...current, [field]: value };
// RTP
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
});
};
const updateStageTier = (stageKey, tierIndex, patch) => {
setForm((current) =>
correctAllStageProbabilities({
...current,
stages: current.stages.map((stage) =>
stage.stage === stageKey
? {
...stage,
tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier))
}
: stage
)
})
);
};
const addStageTier = (stageKey) => {
setForm((current) =>
correctAllStageProbabilities({
...current,
stages: current.stages.map((stage) =>
stage.stage === stageKey
? {
...stage,
tiers: [
...stage.tiers,
{
enabled: true,
// 10 穿线
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
multiplier: String(nextStageMultiplier(stage.tiers)),
probabilityPercent: "0",
tierId: ""
}
]
}
: stage
)
})
);
};
const removeStageTier = (stageKey, tierIndex) => {
setForm((current) =>
correctAllStageProbabilities({
...current,
stages: current.stages.map((stage) =>
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage
)
})
);
};
const handleSubmit = (event) => {
event.preventDefault();
onSubmit(formToConfig(config, form));
};
const isCreate = mode === "create";
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
return (
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
<form className="ops-config-form" onSubmit={handleSubmit}>
<DialogTitle className="ops-config-title">
<span>{title}</span>
<small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
</small>
</DialogTitle>
<DialogContent dividers>
<div className="ops-config-metrics">
<ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
</div>
<div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数">
<section className="ops-config-section">
<h3>规则身份</h3>
{isCreate ? (
<TextField
fullWidth
required
label="应用"
select
size="small"
value={form.app_code}
onChange={(event) => updateField("app_code", event.target.value)}
>
{appOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)}
/>
<FormControlLabel
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }}
/>
</section>
<section className="ops-config-section">
<h3>RTP 与奖池</h3>
<div className="ops-form-grid">
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
</div>
</section>
</aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary) => (
<button
aria-selected={summary.stageKey === activeStageKey}
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
key={summary.stageKey}
role="tab"
type="button"
onClick={() => setActiveStage(summary.stageKey)}
>
<span>{summary.tabLabel}</span>
</button>
))}
</div>
{activeStageConfig ? (
<div className="ops-stage-stack">
<div className="ops-stage-block__head">
<div>
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
添加奖档
</Button>
</div>
<div className="ops-tier-table">
<div className="ops-tier-head" aria-hidden="true">
<span>倍率</span>
<span>概率 %</span>
<span>高水位</span>
<span>启用</span>
<span>操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
<TextField
required
size="small"
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
type="number"
value={tier.multiplier}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
// 10
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value
})
}
/>
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
value={formatDecimal(tier.probabilityPercent)}
/>
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
/>
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
/>
<Button
disabled={activeStageConfig.tiers.length <= 1}
variant="danger"
onClick={() => removeStageTier(activeStageConfig.stage, index)}
>
删除
</Button>
</div>
))}
</div>
</div>
) : null}
</section>
</div>
</DialogContent>
<DialogActions>
<Button disabled={saving} onClick={onClose}>
取消
</Button>
<Button disabled={saving} type="submit" variant="primary">
{saving ? "保存中" : "保存配置"}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
function ConfigMetric({ label, value }) {
return (
<article className="ops-config-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
);
}
function NumberField({ form, label, name, onChange, step = "1" }) {
return (
<TextField
label={label}
required
size="small"
slotProps={{ htmlInput: { min: 0, step } }}
type="number"
value={form[name]}
onChange={(event) => onChange(name, event.target.value)}
/>
);
}

View File

@ -0,0 +1,33 @@
export function OpsShell({ activeView, children, onViewChange, views }) {
return (
<div className="ops-shell">
<aside className="ops-sidebar" aria-label="运营中心导航">
<div className="ops-brand">
<span>OP</span>
<div>
<strong>运营中心</strong>
<small>Ops Center</small>
</div>
</div>
<nav aria-label="运营中心导航" className="ops-nav">
{views.map((view) => {
const Icon = view.icon;
return (
<button
aria-current={activeView === view.key ? "page" : undefined}
className={activeView === view.key ? "is-active" : ""}
key={view.key}
onClick={() => onViewChange(view.key)}
type="button"
>
<Icon fontSize="small" />
{view.label}
</button>
);
})}
</nav>
</aside>
<main className="ops-main">{children}</main>
</div>
);
}

View File

@ -0,0 +1,15 @@
import Chip from "@mui/material/Chip";
// shared-ui status-badge ops-center
// tone 使"稿/"
export function OpsStatusBadge({ label, tone = "" }) {
return (
<Chip
className={["status-badge", tone ? `status-badge--${tone}` : ""].filter(Boolean).join(" ")}
icon={<span className="status-point" />}
label={label}
size="small"
variant="outlined"
/>
);
}

View File

@ -0,0 +1,124 @@
import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { formatNumber, formatPercentFromPPM } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function OverviewView({ data }) {
const { appSummaries, poolBalances, summary } = data;
return (
<div className="ops-view">
<div className="kpi-grid ops-kpi-grid">
<KpiCard
icon={AppsOutlined}
label="运营应用"
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
value={formatNumber(summary.activeApps)}
/>
<KpiCard
icon={CardGiftcardOutlined}
label="已发布奖池配置"
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
tone="info"
value={formatNumber(summary.configuredPools)}
/>
<KpiCard
icon={CasinoOutlined}
label="抽奖次数"
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
tone="warning"
value={formatNumber(summary.totalDraws)}
/>
<KpiCard
icon={SavingsOutlined}
label="奖池金币"
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
tone="success"
value={formatNumber(summary.poolBalanceTotal)}
/>
</div>
<DataTable
columns={poolBalanceColumns}
emptyLabel="暂无奖池余额"
items={poolBalances}
minWidth="920px"
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
title="奖池实时水位"
total={poolBalances.length}
/>
<DataTable
columns={appSummaryColumns}
emptyLabel="暂无抽奖数据"
items={appSummaries}
minWidth="920px"
rowKey={(item) => item.app_code}
title="各应用抽奖汇总"
total={appSummaries.length}
/>
</div>
);
}
const poolBalanceColumns = [
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" },
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" },
{
key: "materialized",
label: "水位状态",
// materialized=false
render: (item) =>
item.materialized === false ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
width: "minmax(132px, 0.9fr)"
},
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) },
{ key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor), width: "minmax(96px, 0.8fr)" },
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
{
key: "updated_at_ms",
label: "更新时间",
render: (item) =>
item.materialized === false ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
width: "minmax(150px, 1fr)"
}
];
const appSummaryColumns = [
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
{
key: "actual_rtp_ppm",
label: "实际 RTP",
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-")
},
{
key: "exceptions",
label: "发放异常",
// 0 0
render: (item) => {
const pending = Number(item.pending_draws || 0);
const failed = Number(item.failed_draws || 0);
if (!pending && !failed) {
return <OpsStatusBadge label="正常" tone="succeeded" />;
}
return <OpsStatusBadge label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`} tone={failed ? "danger" : "warning"} />;
},
width: "minmax(170px, 1.2fr)"
}
];
function countBySource(apps = [], source) {
return apps.filter((item) => (item.source || "registry") === source).length;
}

View File

@ -0,0 +1,158 @@
import { stageOptions } from "@/features/lucky-gift/constants.js";
import {
applyProbabilityMap,
correctRTPProbabilities,
decimal,
expectedRTPPercent,
formatDecimal,
probabilityTotal
} from "@/shared/utils/rtpProbability.js";
import { DEFAULT_POOL_ID } from "./api.js";
// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。
// 与展示层分离,弹窗组件只负责渲染和事件绑定。
export function configToForm(config = {}) {
const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent);
return {
app_code: config.app_code || config.appCode || "",
control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent),
enabled: Boolean(config.enabled),
gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference),
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent),
settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager),
stages: normalizeFormStages(config.stages, targetRTPPercent),
target_rtp_percent: targetRTPPercent
};
}
export function formToConfig(config, form) {
return {
...config,
app_code: form.app_code,
control_band_percent: numberFromForm(form.control_band_percent),
enabled: Boolean(form.enabled),
gift_price_reference: numberFromForm(form.gift_price_reference),
pool_id: form.pool_id,
pool_rate_percent: numberFromForm(form.pool_rate_percent),
settlement_window_wager: numberFromForm(form.settlement_window_wager),
stages: form.stages,
target_rtp_percent: numberFromForm(form.target_rtp_percent)
};
}
// 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
// 不允许运营手工填概率绕过服务端的闭合校验。
export function correctAllStageProbabilities(form) {
return {
...form,
stages: (form.stages || []).map((stage) => {
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
multiplier: (item) => item.multiplier
});
return {
...stage,
tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers
};
})
};
}
export function stageSummary(stage, form) {
const probability = probabilityTotal(stage.tiers);
const expected = expectedRTPPercent(stage.tiers, {
multiplier: (item) => item.multiplier
});
const target = Number(form.target_rtp_percent || 0);
const band = Number(form.control_band_percent || 0);
const probabilityClosed = Math.abs(probability - 100) < 0.0001;
const rtpInBand = expected >= target - band && expected <= target + band;
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
return {
expected,
ok: probabilityClosed && rtpInBand,
probability,
probabilityClosed,
rtpInBand,
stageKey: stage.stage,
stageLabel: fullLabel,
// 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。
status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合",
tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "")
};
}
export function nextStageMultiplier(tiers = []) {
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
if (!positive.length) {
return 1;
}
const max = Math.max(...positive);
if (max >= 5) {
return max * 2;
}
if (max >= 2) {
return 5;
}
return max + 1;
}
function normalizeFormStages(stages, targetRTPPercent = 95) {
const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage]));
return correctAllStageProbabilities({
stages: stageOptions.map(([stageKey]) => {
const source = byStage.get(stageKey);
const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey);
return {
stage: stageKey,
tiers: sourceTiers.map((tier) => ({
enabled: tier.enabled !== false,
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
multiplier: formNumber(tier.multiplier),
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
tierId: tier.tier_id || tier.tierId || ""
}))
};
}),
target_rtp_percent: targetRTPPercent
}).stages;
}
function defaultStageTiers(stage) {
if (stage === "advanced") {
return [
defaultTier("advanced_none", 0, 27),
defaultTier("advanced_1x", 1, 60),
defaultTier("advanced_2x", 2, 10),
defaultTier("advanced_5x", 5, 3, { high_water_only: true })
];
}
return [
defaultTier(`${stage}_none`, 0, 10),
defaultTier(`${stage}_0_5x`, 0.5, 20),
defaultTier(`${stage}_1x`, 1, 55),
defaultTier(`${stage}_2x`, 2, 15)
];
}
function defaultTier(tierID, multiplier, probabilityPercent, options = {}) {
return {
enabled: true,
highWaterOnly: Boolean(options.high_water_only),
multiplier,
probabilityPercent,
tierId: tierID
};
}
export function formNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? String(parsed) : "0";
}
export function numberFromForm(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}

32
ops-center/src/format.js Normal file
View File

@ -0,0 +1,32 @@
export function formatNumber(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return "-";
}
return new Intl.NumberFormat("zh-CN").format(parsed);
}
export function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return "-";
}
return `${parsed.toFixed(2)}%`;
}
// 服务端 RTP 一律以 ppm百万分比存储避免浮点误差展示层统一在这里换算成百分比。
export function formatPercentFromPPM(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return "-";
}
return `${(parsed / 10_000).toFixed(2)}%`;
}
export function formatMultiplierFromPPM(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return "-";
}
return `${(parsed / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}x`;
}

22
ops-center/src/main.jsx Normal file
View File

@ -0,0 +1,22 @@
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import React from "react";
import { createRoot } from "react-dom/client";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { theme } from "@/theme.js";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
import "@/styles/tokens.css";
import "@/styles/shared-ui.css";
import "@/styles/responsive.css";
import "./styles/index.css";
createRoot(document.getElementById("ops-center-root")).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<OpsCenterApp />
</ToastProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,407 @@
:root {
color: var(--text-primary);
background: var(--bg-page);
font-family: var(--font-system);
}
body {
margin: 0;
min-width: 320px;
background: var(--bg-page);
}
#ops-center-root {
min-height: 100vh;
}
/* ---- 应用外壳:左侧固定导航 + 内容区宽度对齐主后台侧边栏248px ---- */
.ops-shell {
display: grid;
min-height: 100vh;
grid-template-columns: 248px minmax(0, 1fr);
}
.ops-sidebar {
display: flex;
flex-direction: column;
gap: var(--space-5);
border-right: 1px solid var(--border);
background: var(--bg-card);
padding: var(--space-5) var(--space-3);
}
.ops-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-2);
}
.ops-brand > span {
display: inline-grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: var(--radius-control);
background: var(--primary);
color: var(--active-contrast, #fff);
font-size: 13px;
font-weight: 800;
}
.ops-brand strong {
display: block;
font-size: 16px;
line-height: 1.25;
}
.ops-brand small {
color: var(--text-tertiary);
font-size: 11px;
}
.ops-nav {
display: grid;
gap: var(--space-2);
}
.ops-nav button {
display: flex;
width: 100%;
height: 38px;
align-items: center;
gap: var(--space-3);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0 var(--space-3);
text-align: left;
transition:
background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.ops-nav button:hover {
background: var(--primary-hover);
color: var(--text-primary);
}
/* active 优先级必须高于 hoverhover 到别的项时当前页仍要保持高亮底色 */
.ops-nav button.is-active,
.ops-nav button.is-active:hover {
background: var(--primary-surface);
color: var(--primary);
font-weight: 700;
}
.ops-main {
min-width: 0;
background: var(--bg-page);
}
/* ---- 页面骨架 ---- */
.ops-page {
display: grid;
gap: var(--space-5);
padding: var(--space-6);
}
.ops-view {
display: grid;
gap: var(--space-5);
}
.ops-kpi-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ops-status {
display: inline-flex;
height: 28px;
align-items: center;
border: 1px solid var(--success-border);
border-radius: 999px;
background: var(--success-surface);
color: var(--success);
font-size: 12px;
font-weight: 700;
padding: 0 var(--space-3);
}
.ops-status.is-loading {
border-color: var(--info-border);
background: var(--info-surface);
color: var(--info);
}
.ops-alert {
border: 1px solid var(--warning-border);
border-radius: var(--radius-card);
background: var(--warning-surface);
color: var(--warning);
font-weight: 700;
padding: var(--space-3) var(--space-4);
}
/* ---- 幸运礼物配置弹窗 ---- */
.ops-config-form {
display: contents;
}
.ops-config-title {
display: grid;
gap: var(--space-1);
}
.ops-config-title small {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 700;
}
.ops-config-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.ops-config-metric {
display: grid;
min-width: 0;
gap: var(--space-1);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong, #f8fafc);
padding: 10px var(--space-3);
}
.ops-config-metric span {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-config-metric strong {
overflow: hidden;
color: var(--text-primary);
font-size: 15px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-config-layout {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
align-items: start;
gap: var(--space-4);
}
.ops-config-params {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.ops-config-section {
display: grid;
gap: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
padding: var(--space-4);
}
.ops-config-section h3 {
margin: 0;
color: var(--text-primary);
font-size: 15px;
line-height: 1.3;
}
.ops-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.ops-stage-designer {
display: grid;
min-width: 0;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.ops-stage-tabs {
display: flex;
min-width: 0;
gap: var(--space-1);
padding: 0 var(--space-3);
border-bottom: 1px solid var(--border);
}
.ops-stage-tab {
display: inline-flex;
min-width: 0;
height: 38px;
align-items: center;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 800;
padding: 0 var(--space-4);
text-align: center;
transition:
background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.ops-stage-tab:hover {
background: var(--primary-hover);
}
.ops-stage-tab.is-active {
border-bottom-color: var(--primary);
color: var(--text-primary);
}
/* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */
.ops-stage-tab.is-warn {
color: var(--warning);
}
.ops-stage-tab.is-active.is-warn {
border-bottom-color: var(--warning);
color: var(--warning);
}
.ops-stage-stack {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.ops-stage-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.ops-stage-block__head > div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.ops-stage-block__head h3 {
margin: 0;
font-size: 15px;
}
.ops-tier-table {
display: grid;
min-width: 0;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.ops-tier-head,
.ops-tier-row {
display: grid;
box-sizing: border-box;
min-width: 520px;
grid-template-columns:
minmax(110px, 1fr)
minmax(110px, 1fr)
minmax(64px, auto)
minmax(64px, auto)
minmax(72px, auto);
align-items: center;
gap: var(--space-2);
}
.ops-tier-head {
min-height: 34px;
border-bottom: 1px solid var(--row-border);
background: var(--bg-card-strong, #f8fafc);
color: var(--text-tertiary);
font-size: 12px;
font-weight: 900;
padding: 0 var(--space-3);
}
.ops-tier-row {
border-bottom: 1px solid var(--row-border);
padding: var(--space-2) var(--space-3);
}
.ops-tier-row:last-child {
border-bottom: 0;
}
/* ---- 响应式 ---- */
@media (max-width: 960px) {
.ops-shell {
grid-template-columns: 1fr;
}
.ops-sidebar {
min-height: auto;
}
.ops-nav,
.ops-kpi-grid,
.ops-config-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ops-config-layout {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.ops-page {
padding: var(--space-4);
}
.ops-nav,
.ops-kpi-grid,
.ops-config-metrics,
.ops-form-grid,
.ops-tier-row {
grid-template-columns: 1fr;
}
.ops-tier-head {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ops-nav button,
.ops-stage-tab {
transition: none;
}
}

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,9 +3,11 @@ 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 (
<AppUserDetailProvider>
<Routes>
{publicRoutes.map((route) => (
<Route key={route.path} path={route.path} element={<DeferredPage route={route} />} />
@ -28,6 +30,7 @@ function App() {
</Route>
<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

@ -134,6 +134,7 @@ export const fallbackNavigation = [
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
routeNavItem("host-org-coin-seller-sub-applications", { icon: ReceiptLongOutlined }),
routeNavItem("host-withdrawals", { icon: ReceiptLongOutlined }),
],
},
@ -191,6 +192,7 @@ export const fallbackNavigation = [
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
routeNavItem("policy-template", { icon: SettingsApplicationsOutlined }),
],
},
{

View File

@ -6,6 +6,8 @@ export const PERMISSIONS = {
userStatus: "user:status",
userResetPassword: "user:reset-password",
userExport: "user:export",
jobView: "job:view",
jobCancel: "job:cancel",
teamView: "team:view",
teamCreate: "team:create",
teamUpdate: "team:update",
@ -41,7 +43,11 @@ export const PERMISSIONS = {
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
coinSellerSubApplicationView: "coin-seller-sub-application:view",
coinSellerSubApplicationAudit: "coin-seller-sub-application:audit",
hostWithdrawalView: "host-withdrawal:view",
pointWithdrawalConfigView: "point-withdrawal-config:view",
pointWithdrawalConfigUpdate: "point-withdrawal-config:update",
financeView: "finance:view",
financeApplicationCreate: "finance-application:create",
financeApplicationAudit: "finance-application:audit",
@ -64,6 +70,11 @@ export const PERMISSIONS = {
reportView: "report:view",
giftDiamondView: "gift-diamond:view",
giftDiamondUpdate: "gift-diamond:update",
policyTemplateView: "policy-template:view",
policyTemplateCreate: "policy-template:create",
policyInstanceCreate: "policy-instance:create",
policyInstanceUpdate: "policy-instance:update",
policyInstancePublish: "policy-instance:publish",
fullServerNoticeView: "full-server-notice:view",
fullServerNoticeSend: "full-server-notice:send",
paymentBillView: "payment-bill:view",
@ -104,6 +115,9 @@ export const PERMISSIONS = {
appUserUpdate: "app-user:update",
appUserStatus: "app-user:status",
appUserPassword: "app-user:password",
appUserToken: "app-user:token",
appUserLevel: "app-user:level",
appUserExport: "app-user:export",
prettyIdView: "pretty-id:view",
prettyIdUpdate: "pretty-id:update",
prettyIdGenerate: "pretty-id:generate",
@ -214,6 +228,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",
@ -246,6 +261,7 @@ export const MENU_CODES = {
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
operationGiftDiamond: "operation-gift-diamond",
policyTemplate: "policy-template",
operationFullServerNotice: "operation-full-server-notice",
luckyGift: "lucky-gift",
wheel: "wheel",
@ -278,7 +294,9 @@ export const MENU_CODES = {
hostAgencyPolicy: "host-agency-policy",
hostSalarySettlement: "host-salary-settlement",
hostOrgCoinSellers: "host-org-coin-sellers",
hostOrgCoinSellerSubApplications: "host-org-coin-seller-sub-applications",
hostWithdrawals: "host-withdrawals",
hostOrgPointWithdrawalConfig: "host-org-point-withdrawal-config",
payment: "payment",
paymentBillList: "payment-bill-list",
paymentRechargeProducts: "payment-recharge-products",

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

@ -1,5 +1,6 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { defaultAdminPath } from "./routeConfig";
@ -12,7 +13,7 @@ export function RequireAuth() {
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
return <Navigate to={buildLoginRedirectPath(location)} state={{ from: location }} replace />;
}
return <Outlet />;

View File

@ -19,6 +19,7 @@ import { luckyGiftRoutes } from "@/features/lucky-gift/routes.js";
import { menusRoutes } from "@/features/menus/routes.js";
import { operationsRoutes } from "@/features/operations/routes.js";
import { paymentRoutes } from "@/features/payment/routes.js";
import { policyConfigRoutes } from "@/features/policy-config/routes.js";
import { registrationRewardRoutes } from "@/features/registration-reward/routes.js";
import { redPacketRoutes } from "@/features/red-packets/routes.js";
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
@ -65,6 +66,7 @@ export const adminRoutes: AdminRoute[] = [
...vipConfigRoutes,
...resourceRoutes,
...operationsRoutes,
...policyConfigRoutes,
...luckyGiftRoutes,
...paymentRoutes,
...gameRoutes,

View File

@ -1,12 +1,61 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { createSystemMessagePushFanout } from "./api";
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
import {
createH5Link,
createSystemMessagePushFanout,
deleteH5Link,
listH5Links,
updateH5Link,
updateH5Links,
} from "./api";
afterEach(() => {
setAccessToken("");
setSelectedAppCode("lalu");
vi.unstubAllGlobals();
});
test("H5 config API pins every request to its explicit app scope", async () => {
setSelectedAppCode("lalu");
const link = {
appCode: "huwaa",
key: "achievement",
label: "成就",
updatedAtMs: 1000,
url: "https://h5.example.com/achievement",
};
vi.stubGlobal(
"fetch",
vi.fn(async (_url, init) => {
const method = String(init?.method || "GET");
const data = method === "GET" || (method === "PUT" && !String(_url).endsWith("/achievement"))
? { items: [link], total: 1 }
: method === "DELETE"
? { deleted: true }
: link;
return new Response(JSON.stringify({ code: 0, data }), { status: method === "POST" ? 201 : 200 });
}),
);
const payload = { key: link.key, label: link.label, url: link.url };
await listH5Links(" Huwaa ");
await createH5Link("huwaa", payload);
await updateH5Links("huwaa", { items: [payload] });
await updateH5Link("huwaa", link.key, payload);
await deleteH5Link("huwaa", link.key);
const calls = vi.mocked(fetch).mock.calls;
expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "POST", "PUT", "PUT", "DELETE"]);
calls.forEach(([, init]) => {
expect(init?.headers).toMatchObject({ "X-App-Code": "huwaa" });
});
expect(String(calls[0][0])).toContain("/api/v1/admin/app-config/h5-links");
expect(String(calls[3][0])).toContain("/api/v1/admin/app-config/h5-links/achievement");
expect(JSON.parse(String(calls[1][1]?.body))).toEqual(payload);
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({ items: [payload] });
expect(JSON.parse(String(calls[3][1]?.body))).toEqual(payload);
});
test("system message push fanout API keeps existing operations path", async () => {
vi.stubGlobal(
"fetch",

View File

@ -53,47 +53,57 @@ export function createSystemMessagePushFanout(payload: SystemMessagePushPayload)
);
}
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
export function listH5Links(appCode: string): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.listH5Links;
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
headers: appScopeHeaders(appCode),
method: endpoint.method,
});
}
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
export function updateH5Links(appCode: string, payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
const endpoint = API_ENDPOINTS.updateH5Links;
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
apiEndpointPath(API_OPERATIONS.updateH5Links),
{
body: payload,
headers: appScopeHeaders(appCode),
method: endpoint.method,
},
);
}
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
export function createH5Link(appCode: string, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
const endpoint = API_ENDPOINTS.createH5Link;
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
body: payload,
headers: appScopeHeaders(appCode),
method: endpoint.method,
});
}
export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
export function updateH5Link(appCode: string, key: EntityId, payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
const endpoint = API_ENDPOINTS.updateH5Link;
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
body: payload,
headers: appScopeHeaders(appCode),
method: endpoint.method,
});
}
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
export function deleteH5Link(appCode: string, key: EntityId): Promise<{ deleted: boolean }> {
const endpoint = API_ENDPOINTS.deleteH5Link;
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
headers: appScopeHeaders(appCode),
method: endpoint.method,
});
}
function appScopeHeaders(appCode: string) {
// H5 配置是强 App 隔离数据;显式固定请求头可避免用户切换 App 时全局请求上下文变化导致写入错误租户。
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
}
export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreTabDto>> {
const endpoint = API_ENDPOINTS.listExploreTabs;
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {

View File

@ -1,4 +1,6 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { getSelectedAppCode } from "@/shared/api/request";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
@ -11,45 +13,66 @@ const emptyData = { items: [], total: 0 };
const emptyForm = () => ({ key: "", label: "", url: "" });
export function useH5ConfigPage() {
const { appCode } = useAppScope();
const abilities = useAppConfigAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [activeAction, setActiveAction] = useState("");
const [actionAppCode, setActionAppCode] = useState("");
const [editingItem, setEditingItem] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const queryFn = useCallback(() => listH5Links(), []);
const queryFn = useCallback(() => listH5Links(appCode), [appCode]);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
enabled: Boolean(appCode),
errorMessage: "加载 H5 配置失败",
initialData: emptyData,
queryKey: ["app-config", "h5-links"]
queryKey: ["app-config", "h5-links", appCode]
});
const openCreate = () => {
setEditingItem(null);
setForm(emptyForm());
setActionAppCode(appCode);
setActiveAction("create");
};
const openEdit = (item) => {
const itemAppCode = normalizeAppCode(item.appCode) || appCode;
setEditingItem(item);
setForm({ key: item.key || "", label: item.label || "", url: item.url || "" });
// 响应中的 appCode 是该行数据的归属凭证若页面正在切换effect 会发现它与当前 scope 不一致并关闭弹窗。
setActionAppCode(itemAppCode);
setActiveAction("edit");
};
const closeEdit = () => {
const closeEdit = useCallback(() => {
setActiveAction("");
setActionAppCode("");
setEditingItem(null);
setForm(emptyForm());
};
}, []);
useEffect(() => {
if (!activeAction || sameAppCode(actionAppCode, appCode)) {
return;
}
// 弹窗内容来自旧 App切换后必须丢弃不能让同名 key 被保存到新 App。
closeEdit();
}, [activeAction, actionAppCode, appCode, closeEdit]);
const submitEdit = async (event) => {
event.preventDefault();
if (!isCurrentActionScope(actionAppCode, appCode)) {
closeEdit();
showToast("应用已切换,请重新操作", "warning");
return;
}
if (!editingItem) {
const payload = parseForm(h5LinkUpdateSchema, form);
setLoadingAction("create");
try {
await createH5Link(payload);
await createH5Link(actionAppCode, payload);
closeEdit();
await reload();
showToast("H5配置已新增", "success");
@ -63,7 +86,7 @@ export function useH5ConfigPage() {
const payload = parseForm(h5LinkUpdateSchema, { ...form, key: editingItem.key });
setLoadingAction("edit");
try {
await updateH5Link(editingItem.key, payload);
await updateH5Link(actionAppCode, editingItem.key, payload);
closeEdit();
await reload();
showToast("H5配置已更新", "success");
@ -75,6 +98,7 @@ export function useH5ConfigPage() {
};
const removeH5Link = async (item) => {
const targetAppCode = normalizeAppCode(item.appCode) || appCode;
const ok = await confirm({
confirmText: "删除",
message: item.label || item.key,
@ -84,9 +108,13 @@ export function useH5ConfigPage() {
if (!ok) {
return;
}
if (!isCurrentActionScope(targetAppCode, appCode)) {
showToast("应用已切换,请重新操作", "warning");
return;
}
setLoadingAction(`delete:${item.key}`);
try {
await deleteH5Link(item.key);
await deleteH5Link(targetAppCode, item.key);
await reload();
showToast("H5配置已删除", "success");
} catch (err) {
@ -114,3 +142,18 @@ export function useH5ConfigPage() {
submitEdit
};
}
function isCurrentActionScope(actionAppCode, appCode) {
// Header 的全局值在 AppScope state 提交前已更新,双重校验能挡住切换瞬间点击提交的竞态。
return Boolean(normalizeAppCode(actionAppCode))
&& sameAppCode(actionAppCode, appCode)
&& sameAppCode(actionAppCode, getSelectedAppCode());
}
function sameAppCode(left, right) {
return normalizeAppCode(left) === normalizeAppCode(right);
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -0,0 +1,179 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { setSelectedAppCode } from "@/shared/api/request";
const mocks = vi.hoisted(() => ({
appCode: "lalu",
confirm: vi.fn(),
createH5Link: vi.fn(),
deleteH5Link: vi.fn(),
listH5Links: vi.fn(),
queryFn: null,
queryOptions: null,
reload: vi.fn(),
showToast: vi.fn(),
updateH5Link: vi.fn()
}));
vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
useAppScope: () => ({ appCode: mocks.appCode })
}));
vi.mock("@/features/app-config/api", () => ({
createH5Link: mocks.createH5Link,
deleteH5Link: mocks.deleteH5Link,
listH5Links: mocks.listH5Links,
updateH5Link: mocks.updateH5Link
}));
vi.mock("@/features/app-config/permissions.js", () => ({
useAppConfigAbilities: () => ({ canUpdate: true, canView: true })
}));
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
useAdminQuery: (queryFn, options) => {
mocks.queryFn = queryFn;
mocks.queryOptions = options;
return {
data: { items: [], total: 0 },
error: "",
loading: false,
reload: mocks.reload
};
}
}));
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
useConfirm: () => mocks.confirm
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: () => ({ showToast: mocks.showToast })
}));
import { useH5ConfigPage } from "./useH5ConfigPage.js";
beforeEach(() => {
mocks.appCode = "lalu";
mocks.confirm.mockResolvedValue(true);
mocks.createH5Link.mockResolvedValue({});
mocks.deleteH5Link.mockResolvedValue({ deleted: true });
mocks.listH5Links.mockResolvedValue({ items: [], total: 0 });
mocks.reload.mockResolvedValue({ items: [], total: 0 });
mocks.updateH5Link.mockResolvedValue({});
setSelectedAppCode("lalu");
});
afterEach(() => {
vi.clearAllMocks();
setSelectedAppCode("lalu");
});
test("uses appCode in the H5 query key and read request", async () => {
const { rerender } = renderHook(() => useH5ConfigPage());
expect(mocks.queryOptions.queryKey).toEqual(["app-config", "h5-links", "lalu"]);
await mocks.queryFn();
expect(mocks.listH5Links).toHaveBeenLastCalledWith("lalu");
mocks.appCode = "huwaa";
setSelectedAppCode("huwaa");
rerender();
expect(mocks.queryOptions.queryKey).toEqual(["app-config", "h5-links", "huwaa"]);
await mocks.queryFn();
expect(mocks.listH5Links).toHaveBeenLastCalledWith("huwaa");
});
test("closes an edit dialog when the selected App changes", () => {
const item = {
appCode: "lalu",
key: "achievement",
label: "成就",
updatedAtMs: 1000,
url: "https://h5.example.com/achievement"
};
const { result, rerender } = renderHook(() => useH5ConfigPage());
act(() => result.current.openEdit(item));
expect(result.current.activeAction).toBe("edit");
mocks.appCode = "huwaa";
setSelectedAppCode("huwaa");
rerender();
expect(result.current.activeAction).toBe("");
expect(result.current.editingItem).toBeNull();
expect(result.current.form).toEqual({ key: "", label: "", url: "" });
});
test("does not submit stale form state after an App switch", async () => {
const { result, rerender } = renderHook(() => useH5ConfigPage());
act(() => {
result.current.openEdit({
appCode: "lalu",
key: "achievement",
label: "成就",
updatedAtMs: 1000,
url: "https://h5.example.com/achievement"
});
});
mocks.appCode = "huwaa";
setSelectedAppCode("huwaa");
rerender();
await act(async () => {
await result.current.submitEdit({ preventDefault: vi.fn() });
});
expect(mocks.createH5Link).not.toHaveBeenCalled();
expect(mocks.updateH5Link).not.toHaveBeenCalled();
expect(mocks.showToast).toHaveBeenCalledWith("应用已切换,请重新操作", "warning");
});
test("pins an update mutation to the App that owns the edited row", async () => {
const { result } = renderHook(() => useH5ConfigPage());
act(() => {
result.current.openEdit({
appCode: "lalu",
key: "achievement",
label: "成就",
updatedAtMs: 1000,
url: "https://h5.example.com/achievement"
});
});
await act(async () => {
await result.current.submitEdit({ preventDefault: vi.fn() });
});
expect(mocks.updateH5Link).toHaveBeenCalledWith("lalu", "achievement", {
key: "achievement",
label: "成就",
url: "https://h5.example.com/achievement"
});
});
test("aborts delete when App changes while confirmation is open", async () => {
let resolveConfirm;
mocks.confirm.mockReturnValue(new Promise((resolve) => {
resolveConfirm = resolve;
}));
const { result, rerender } = renderHook(() => useH5ConfigPage());
let pendingDelete;
act(() => {
pendingDelete = result.current.removeH5Link({ key: "achievement", label: "成就" });
});
mocks.appCode = "huwaa";
setSelectedAppCode("huwaa");
rerender();
await act(async () => {
resolveConfirm(true);
await pendingDelete;
});
expect(mocks.deleteH5Link).not.toHaveBeenCalled();
expect(mocks.showToast).toHaveBeenCalledWith("应用已切换,请重新操作", "warning");
});

View File

@ -1,6 +1,16 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
import {
adjustAppUserLevels,
banAppUser,
createAppUserExport,
getAppUser,
issueAppUserAccessToken,
listAppUsers,
listPrettyDisplayIDs,
recyclePrettyDisplayID,
unbanAppUser,
} from "./api";
afterEach(() => {
setAccessToken("");
@ -351,3 +361,238 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => {
resourceCode: "frame_gold",
});
});
test("app user projection normalizes levels roles ban and successful login fields", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
age: 26,
app_version: "3.2.1",
app_version_at_ms: 1779990000000,
ban: {
active: true,
expires_at_ms: 1790000000000,
id: "ban-1",
permanent: false,
reason: "risk",
source: "admin",
},
birth: "2000-02-29",
build_number: "345",
cumulative_recharge_usd_minor: 12345,
devices: [
{
app_version: "3.2.1",
build_number: "345",
device_id: "android_abcdef",
device_model: "Redmi Note 8 Pro",
first_seen_at_ms: 1770000000000,
imei: "",
last_seen_at_ms: 1781500000000,
manufacturer: "Xiaomi",
os_version: "13",
platform: "android",
},
],
last_login_at_ms: 1780000000000,
last_login_ip: "203.0.113.7",
last_login_ip_at_ms: 1781600000000,
last_login_ip_country_code: "AE",
last_operated_at_ms: 1781000000000,
last_operator: { action: "ban", admin_id: "7", name: "运营" },
levels: {
wealth: {
display_level: 20,
expires_at_ms: 1790000000000,
real_level: 8,
temporary_level_id: "temporary-1",
temporary_target_level: 20,
track: "wealth",
},
},
register_device: "iPhone17,2",
register_device_id: "ios_1F2E3D",
register_os_version: "17.5",
roles: ["主播", "BD"],
third_party_identities: [
{
created_at_ms: 1760000000000,
openid: "g-openid-1",
provider: "google",
union_id: "g-union-1",
},
],
user_id: "10001",
},
}),
),
),
);
const result = await getAppUser("10001");
expect(result).toMatchObject({
age: 26,
appVersion: "3.2.1",
appVersionAtMs: 1779990000000,
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
birth: "2000-02-29",
buildNumber: "345",
cumulativeRechargeUsdMinor: 12345,
devices: [
{
appVersion: "3.2.1",
buildNumber: "345",
deviceId: "android_abcdef",
deviceModel: "Redmi Note 8 Pro",
firstSeenAtMs: 1770000000000,
imei: "",
lastSeenAtMs: 1781500000000,
manufacturer: "Xiaomi",
osVersion: "13",
platform: "android",
},
],
lastLoginAtMs: 1780000000000,
lastLoginIp: "203.0.113.7",
lastLoginIpAtMs: 1781600000000,
lastLoginIpCountryCode: "AE",
lastOperatedAtMs: 1781000000000,
lastOperator: { action: "ban", adminId: "7", name: "运营" },
levels: {
wealth: expect.objectContaining({
displayLevel: 20,
realLevel: 8,
temporaryLevelId: "temporary-1",
temporaryTargetLevel: 20,
}),
},
registerDevice: "iPhone17,2",
registerDeviceId: "ios_1F2E3D",
registerOsVersion: "17.5",
roles: ["主播", "BD"],
thirdPartyIdentities: [
{ createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" },
],
});
});
test("issueAppUserAccessToken posts to access-token endpoint and normalizes the token payload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
access_token: "eyJhbGciOiJIUzI1NiJ9.payload.signature",
device_id: "android_abcdef",
expires_in_sec: 7200,
session_id: "session-1",
session_last_heartbeat_at_ms: 1781700000000,
token_type: "Bearer",
},
}),
),
),
);
const result = await issueAppUserAccessToken("10001");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001/access-token");
expect(init?.method).toBe("POST");
expect(result).toEqual({
accessToken: "eyJhbGciOiJIUzI1NiJ9.payload.signature",
deviceId: "android_abcdef",
expiresInSec: 7200,
sessionId: "session-1",
sessionLastHeartbeatAtMs: 1781700000000,
tokenType: "Bearer",
});
});
test("temporary level adjustment sends wealth and charm in one request", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { user_id: "10001" } }))),
);
const payload = {
adjustments: [
{ duration_days: 7, level: 20, track: "wealth" as const },
{ duration_days: 3, level: 18, track: "charm" as const },
],
reason: "campaign",
};
const result = await adjustAppUserLevels("10001", payload);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001/levels");
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toEqual(payload);
expect(result.userId).toBe("10001");
});
test("ban and unban normalize the status mutation user wrapper", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: {
user: {
ban: { active: true, expires_at_ms: 1790000000000, permanent: false },
status: "banned",
user_id: "10001",
},
},
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ code: 0, data: { user: { ban: { active: false }, status: "active", user_id: "10001" } } }),
),
);
vi.stubGlobal("fetch", fetchMock);
const banned = await banAppUser("10001", { expires_at_ms: 1790000000000, reason: "risk" });
const unbanned = await unbanAppUser("10001", { reason: "cleared" });
expect(banned).toMatchObject({ ban: { active: true, expiresAtMs: 1790000000000 }, status: "banned" });
expect(unbanned).toMatchObject({ ban: { active: false }, status: "active" });
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
expires_at_ms: 1790000000000,
reason: "risk",
});
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ reason: "cleared" });
});
test("app user export creates a task from the current filter snapshot", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({ code: 0, data: { jobId: 77, snapshotAtMs: 1781000000000, status: "pending" } }),
),
),
);
const result = await createAppUserExport({ country: "AE", status: "banned" });
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/exports/app-users?");
expect(String(url)).toContain("country=AE");
expect(String(url)).toContain("status=banned");
expect(init?.method).toBe("POST");
expect(result).toEqual({ jobId: 77, snapshotAtMs: 1781000000000, status: "pending" });
});

View File

@ -1,11 +1,19 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
AdminJobDto,
ApiPage,
AppUserAccessTokenDto,
AppUserBanPayload,
AppUserBanRecordDto,
AppUserDeviceDto,
AppUserExportJobDto,
AppUserLevelAdjustmentPayload,
AppUserLoginLogDto,
AppUserDto,
AppUserPasswordPayload,
AppUserThirdPartyIdentityDto,
AppUserUnbanPayload,
AppUserUpdatePayload,
AdminGrantPrettyDisplayIDResultDto,
EntityId,
@ -76,20 +84,64 @@ export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayl
return normalizeAppUser(data);
}
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method,
});
export async function adjustAppUserLevels(
userId: EntityId,
payload: AppUserLevelAdjustmentPayload,
): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appAdjustUserLevels;
const data = await apiRequest<RawAppUser, AppUserLevelAdjustmentPayload>(
apiEndpointPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }),
{ body: payload, method: endpoint.method },
);
return normalizeAppUser(data);
}
export async function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
export async function banAppUser(userId: EntityId, payload: AppUserBanPayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
const data = await apiRequest<RawAppUserStatusMutation, AppUserBanPayload>(
apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }),
{ body: payload, method: endpoint.method },
);
// 新接口返回 SetUserStatusResult兼容旧环境直接返回 AppUser避免滚动发布期间列表补丁丢失 userId。
return normalizeAppUser(data.user ?? data);
}
export async function unbanAppUser(userId: EntityId, payload: AppUserUnbanPayload = {}): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
const data = await apiRequest<RawAppUserStatusMutation, AppUserUnbanPayload>(
apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }),
{ body: payload, method: endpoint.method },
);
return normalizeAppUser(data.user ?? data);
}
export function createAppUserExport(query: PageQuery = {}): Promise<AppUserExportJobDto> {
const endpoint = API_ENDPOINTS.appExportUsers;
return apiRequest<AppUserExportJobDto>(apiEndpointPath(API_OPERATIONS.appExportUsers), {
method: endpoint.method,
query,
});
}
export function getAppUserExportJob(jobId: EntityId): Promise<AdminJobDto> {
const endpoint = API_ENDPOINTS.getJob;
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function downloadAppUserExport(jobId: EntityId): Promise<Response> {
return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true });
}
// 当前 Token 属于敏感凭证,服务端每次签发都会记录后台操作日志;因此只提供按需获取接口,不随详情自动拉取。
export async function issueAppUserAccessToken(userId: EntityId): Promise<AppUserAccessTokenDto> {
const endpoint = API_ENDPOINTS.appIssueUserAccessToken;
const data = await apiRequest<RawAppUserAccessToken>(
apiEndpointPath(API_OPERATIONS.appIssueUserAccessToken, { id: userId }),
{ method: endpoint.method },
);
return normalizeAppUserAccessToken(data);
}
export function setAppUserPassword(
@ -213,8 +265,17 @@ export async function setPrettyDisplayIDStatus(
// 用户相关接口历史上既返回过 snake_case也返回过 camelCase在 API 边界归一化后,共享用户组件只消费稳定字段。
interface RawAppUser {
age?: number | null;
app_version?: string;
appVersion?: string;
app_version_at_ms?: number;
appVersionAtMs?: number;
avatar?: string;
balances?: RawAppUserAssetBalance[];
ban?: RawAppUserBan;
birth?: string;
build_number?: string;
buildNumber?: string;
coin?: number;
country?: string;
country_display_name?: string;
@ -225,6 +286,7 @@ interface RawAppUser {
createdAtMs?: number;
default_display_user_id?: string;
defaultDisplayUserId?: string;
devices?: RawAppUserDevice[];
diamond?: number;
display_user_id?: string;
displayUserId?: string;
@ -233,6 +295,19 @@ interface RawAppUser {
gender?: string;
last_active_at_ms?: number;
lastActiveAtMs?: number;
last_login_at_ms?: number;
lastLoginAtMs?: number;
last_login_ip?: string;
lastLoginIp?: string;
last_login_ip_at_ms?: number;
lastLoginIpAtMs?: number;
last_login_ip_country_code?: string;
lastLoginIpCountryCode?: string;
last_operated_at_ms?: number;
lastOperatedAtMs?: number;
last_operator?: RawAppUserOperator;
lastOperator?: RawAppUserOperator;
levels?: RawAppUserLevels;
pretty_display_user_id?: string;
prettyDisplayUserId?: string;
pretty_id?: string;
@ -241,14 +316,116 @@ interface RawAppUser {
regionId?: number;
region_name?: string;
regionName?: string;
register_device?: string;
registerDevice?: string;
register_device_id?: string;
registerDeviceId?: string;
register_os_version?: string;
registerOsVersion?: string;
resources?: RawAppUserResource[];
roles?: string[];
status?: string;
third_party_identities?: RawAppUserThirdPartyIdentity[];
thirdPartyIdentities?: RawAppUserThirdPartyIdentity[];
updated_at_ms?: number;
updatedAtMs?: number;
user_id?: EntityId;
userId?: EntityId;
username?: string;
vip?: RawAppUserVIP;
cumulative_recharge_usd_minor?: number;
cumulativeRechargeUsdMinor?: number;
}
interface RawAppUserStatusMutation extends RawAppUser {
user?: RawAppUser;
}
interface RawAppUserThirdPartyIdentity {
created_at_ms?: number;
createdAtMs?: number;
openid?: string;
provider?: string;
union_id?: string;
unionId?: string;
}
interface RawAppUserDevice {
app_version?: string;
appVersion?: string;
build_number?: string;
buildNumber?: string;
device_id?: string;
deviceId?: string;
device_model?: string;
deviceModel?: string;
first_seen_at_ms?: number;
firstSeenAtMs?: number;
imei?: string;
last_seen_at_ms?: number;
lastSeenAtMs?: number;
manufacturer?: string;
os_version?: string;
osVersion?: string;
platform?: string;
}
interface RawAppUserAccessToken {
access_token?: string;
accessToken?: string;
device_id?: string;
deviceId?: string;
expires_in_sec?: number;
expiresInSec?: number;
session_id?: string;
sessionId?: string;
session_last_heartbeat_at_ms?: number;
sessionLastHeartbeatAtMs?: number;
token_type?: string;
tokenType?: string;
}
interface RawAppUserLevel {
display_level?: number;
displayLevel?: number;
display_value?: number;
displayValue?: number;
expires_at_ms?: number;
expiresAtMs?: number;
real_level?: number;
realLevel?: number;
real_total_value?: number;
realTotalValue?: number;
started_at_ms?: number;
startedAtMs?: number;
temporary_level_id?: string;
temporaryLevelId?: string;
temporary_target_level?: number;
temporaryTargetLevel?: number;
track?: string;
}
interface RawAppUserLevels {
charm?: RawAppUserLevel;
game?: RawAppUserLevel;
wealth?: RawAppUserLevel;
}
interface RawAppUserBan {
active?: boolean;
expires_at_ms?: number;
expiresAtMs?: number;
id?: string;
permanent?: boolean;
reason?: string;
source?: string;
}
interface RawAppUserOperator {
action?: string;
admin_id?: EntityId;
adminId?: EntityId;
name?: string;
}
type RawAppUserBrief = RawAppUser;
@ -503,29 +680,133 @@ function normalizePage<TRaw, TItem>(
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
return {
age: optionalNumberValue(item.age),
appVersion: stringValue(item.appVersion ?? item.app_version),
appVersionAtMs: numberValue(item.appVersionAtMs ?? item.app_version_at_ms),
avatar: stringValue(item.avatar),
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
ban: normalizeAppUserBan(item.ban),
birth: stringValue(item.birth),
buildNumber: stringValue(item.buildNumber ?? item.build_number),
coin: numberValue(item.coin),
country: stringValue(item.country),
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
countryName: stringValue(item.countryName ?? item.country_name),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
devices: (item.devices || []).map(normalizeAppUserDevice),
diamond: numberValue(item.diamond),
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
gender: stringValue(item.gender),
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
lastLoginAtMs: numberValue(item.lastLoginAtMs ?? item.last_login_at_ms),
lastLoginIp: stringValue(item.lastLoginIp ?? item.last_login_ip),
lastLoginIpAtMs: numberValue(item.lastLoginIpAtMs ?? item.last_login_ip_at_ms),
lastLoginIpCountryCode: stringValue(item.lastLoginIpCountryCode ?? item.last_login_ip_country_code),
lastOperatedAtMs: numberValue(item.lastOperatedAtMs ?? item.last_operated_at_ms),
lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator),
levels: normalizeAppUserLevels(item.levels),
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: stringValue(item.prettyId ?? item.pretty_id),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
registerDevice: stringValue(item.registerDevice ?? item.register_device),
registerDeviceId: stringValue(item.registerDeviceId ?? item.register_device_id),
registerOsVersion: stringValue(item.registerOsVersion ?? item.register_os_version),
resources: (item.resources || []).map(normalizeAppUserResource),
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
status: stringValue(item.status),
thirdPartyIdentities: (item.thirdPartyIdentities ?? item.third_party_identities ?? []).map(
normalizeAppUserThirdPartyIdentity,
),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
userId: stringValue(item.userId ?? item.user_id),
username: stringValue(item.username),
vip: normalizeAppUserVIP(item.vip),
cumulativeRechargeUsdMinor: numberValue(
item.cumulativeRechargeUsdMinor ?? item.cumulative_recharge_usd_minor,
),
};
}
function normalizeAppUserThirdPartyIdentity(item: RawAppUserThirdPartyIdentity = {}): AppUserThirdPartyIdentityDto {
return {
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
openid: stringValue(item.openid),
provider: stringValue(item.provider),
unionId: stringValue(item.unionId ?? item.union_id),
};
}
function normalizeAppUserDevice(item: RawAppUserDevice = {}): AppUserDeviceDto {
return {
appVersion: stringValue(item.appVersion ?? item.app_version),
buildNumber: stringValue(item.buildNumber ?? item.build_number),
deviceId: stringValue(item.deviceId ?? item.device_id),
deviceModel: stringValue(item.deviceModel ?? item.device_model),
firstSeenAtMs: numberValue(item.firstSeenAtMs ?? item.first_seen_at_ms),
imei: stringValue(item.imei),
lastSeenAtMs: numberValue(item.lastSeenAtMs ?? item.last_seen_at_ms),
manufacturer: stringValue(item.manufacturer),
osVersion: stringValue(item.osVersion ?? item.os_version),
platform: stringValue(item.platform),
};
}
// Token 响应同样做 snake/camel 双形态兼容,复制按钮必须拿到完整 accessToken不能只保留截断展示值。
function normalizeAppUserAccessToken(item: RawAppUserAccessToken = {}): AppUserAccessTokenDto {
return {
accessToken: stringValue(item.accessToken ?? item.access_token),
deviceId: stringValue(item.deviceId ?? item.device_id),
expiresInSec: numberValue(item.expiresInSec ?? item.expires_in_sec),
sessionId: stringValue(item.sessionId ?? item.session_id),
sessionLastHeartbeatAtMs: numberValue(item.sessionLastHeartbeatAtMs ?? item.session_last_heartbeat_at_ms),
tokenType: stringValue(item.tokenType ?? item.token_type),
};
}
function normalizeAppUserLevels(item: RawAppUserLevels = {}) {
return {
charm: normalizeAppUserLevel(item.charm, "charm"),
game: normalizeAppUserLevel(item.game, "game"),
wealth: normalizeAppUserLevel(item.wealth, "wealth"),
};
}
function normalizeAppUserLevel(item: RawAppUserLevel = {}, fallbackTrack: string) {
return {
displayLevel: numberValue(item.displayLevel ?? item.display_level),
displayValue: numberValue(item.displayValue ?? item.display_value),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
realLevel: numberValue(item.realLevel ?? item.real_level),
realTotalValue: numberValue(item.realTotalValue ?? item.real_total_value),
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
temporaryLevelId: stringValue(item.temporaryLevelId ?? item.temporary_level_id),
temporaryTargetLevel: numberValue(item.temporaryTargetLevel ?? item.temporary_target_level),
track: stringValue(item.track || fallbackTrack),
};
}
function normalizeAppUserBan(item: RawAppUserBan = {}) {
return {
active: Boolean(item.active),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
id: stringValue(item.id),
permanent: Boolean(item.permanent),
reason: stringValue(item.reason),
source: stringValue(item.source),
};
}
function normalizeAppUserOperator(item?: RawAppUserOperator) {
if (!item) {
return undefined;
}
return {
action: stringValue(item.action),
adminId: stringValue(item.adminId ?? item.admin_id),
name: stringValue(item.name),
};
}
@ -699,6 +980,14 @@ function numberValue(value: unknown): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function optionalNumberValue(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
// 靓号明细包含池号和后台发放两类来源pool 可能为空,归一化后页面只判断 source/status 即可。
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";

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(--gender-female);
}
.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%;
@ -413,17 +463,109 @@
line-height: 1.3;
}
.levelPrimary {
color: var(--text-primary);
font-weight: 800;
white-space: nowrap;
}
.rolesValue {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.banCountdown {
color: var(--danger);
font-size: 12px;
font-weight: 750;
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);
@ -433,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 {
@ -452,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 {
@ -472,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,
@ -669,6 +1027,20 @@
gap: var(--space-3);
}
.levelForm {
display: grid;
gap: var(--space-3);
}
.levelTrackForm {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
@media (max-width: 760px) {
.prettyTabsBar {
padding: 0 var(--space-3);

View File

@ -1,11 +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 { AppUserDetailView } from "@/features/app-users/components/AppUserDetailView.jsx";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserDetailProvider({ children }) {
@ -35,6 +33,7 @@ export function AppUserDetailProvider({ children }) {
try {
const latestUser = await getAppUser(seedUser.userId);
//
if (requestSeqRef.current === requestSeq) {
setDetailUser({ ...seedUser, ...latestUser });
}
@ -70,151 +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={formatGender(user.gender)} />
</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="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
<DetailItem label="金币" value={formatNumber(user.coin)} />
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
<DetailItem label="国家" value={formatCountry(user)} />
<DetailItem
label="区域"
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
<SideDrawer
contentClassName={styles.detailDrawerBody}
open={open}
title="用户详情"
width="detail"
onClose={onClose}
>
<AppUserDetailView
fullDetailsHref={`/app/users/${encodeURIComponent(user.userId)}`}
loading={loading}
user={user}
onOpenFullDetails={onClose}
/>
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
<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>
);
}
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 {};
@ -244,62 +117,6 @@ function normalizeDetailSeed(source) {
};
}
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
return gender || "-";
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString() : "-";
}
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

@ -0,0 +1,139 @@
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, issueAppUserAccessToken } from "@/features/app-users/api";
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() }));
beforeEach(() => {
vi.clearAllMocks();
});
test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => {
vi.mocked(getAppUser).mockResolvedValue({
age: 26,
appVersion: "3.2.1",
appVersionAtMs: 1780990000000,
avatar: "",
ban: { active: true, permanent: true, reason: "risk" },
birth: "2000-02-29",
buildNumber: "345",
coin: 1200,
countryDisplayName: "阿联酋",
createdAtMs: 1780000000000,
cumulativeRechargeUsdMinor: 12345,
defaultDisplayUserId: "163337",
devices: [
{
appVersion: "3.2.1",
buildNumber: "345",
deviceId: "android_abcdef",
deviceModel: "Redmi Note 8 Pro",
firstSeenAtMs: 1770000000000,
imei: "",
lastSeenAtMs: 1781500000000,
manufacturer: "Xiaomi",
osVersion: "13",
platform: "android",
},
],
diamond: 88,
displayUserId: "163337",
gender: "female",
lastLoginAtMs: 1781000000000,
lastLoginIp: "203.0.113.7",
lastLoginIpAtMs: 1781600000000,
lastLoginIpCountryCode: "AE",
lastOperatedAtMs: 1782000000000,
lastOperator: { action: "ban", adminId: "7", name: "运营" },
levels: {
charm: { displayLevel: 18, realLevel: 7, temporaryTargetLevel: 18, expiresAtMs: 1790000000000 },
game: { displayLevel: 9, realLevel: 9 },
wealth: { displayLevel: 20, realLevel: 8, temporaryTargetLevel: 20, expiresAtMs: 1790000000000 },
},
prettyDisplayUserId: "VIP888",
prettyId: "pretty-888",
regionName: "中东区",
registerDevice: "iPhone17,2",
registerDeviceId: "ios_1F2E3D",
registerOsVersion: "17.5",
roles: ["主播", "BD"],
status: "banned",
thirdPartyIdentities: [
{ createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" },
],
updatedAtMs: 1782000000000,
userId: "10001",
username: "tester",
vip: { active: true, expiresAtMs: 1795000000000, level: 3, name: "黄金会员" },
});
vi.mocked(issueAppUserAccessToken).mockResolvedValue({
accessToken: "eyJhbGciOiJIUzI1NiJ9.this-is-a-long-enough-jwt-payload.signature-tail",
deviceId: "android_abcdef",
expiresInSec: 7200,
sessionId: "session-1",
sessionLastHeartbeatAtMs: 1781700000000,
tokenType: "Bearer",
});
render(
<ToastProvider>
<MemoryRouter initialEntries={["/app/users?status=active"]}>
<AppUserDetailProvider>
<DetailTrigger />
</AppUserDetailProvider>
</MemoryRouter>
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "打开详情" }));
expect((await screen.findAllByText("VIP888")).length).toBeGreaterThan(0);
expect(screen.getByText("注册设备")).toBeInTheDocument();
expect(screen.getByText("iPhone17,2")).toBeInTheDocument();
expect(screen.getByText("主播、BD")).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();
expect(screen.getByText("203.0.113.7")).toBeInTheDocument();
expect(screen.getByText(/AE · 记录于/)).toBeInTheDocument();
expect(screen.getByText("3.2.1 (build 345)")).toBeInTheDocument();
expect(screen.getByText("google")).toBeInTheDocument();
expect(screen.getByText("g-openid-1")).toBeInTheDocument();
// Token Token
expect(issueAppUserAccessToken).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "获取 Token" }));
expect(await screen.findByText("eyJhbGciOiJIUz…ature-tail")).toBeInTheDocument();
expect(issueAppUserAccessToken).toHaveBeenCalledWith("10001");
expect(screen.getByText("有效期 7200 秒")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "设备" }));
expect(screen.getByText("Xiaomi Redmi Note 8 Pro")).toBeInTheDocument();
expect(screen.getByText("android_abcdef")).toBeInTheDocument();
expect(screen.getByText("注册系统版本")).toBeInTheDocument();
expect(screen.getByText("17.5")).toBeInTheDocument();
expect(screen.getByText("ios_1F2E3D")).toBeInTheDocument();
fireEvent.click(screen.getByRole("link", { name: /完整详情/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
function DetailTrigger() {
const { openAppUserDetail } = useAppUserDetail();
return (
<button type="button" onClick={() => openAppUserDetail({ userId: "10001", username: "tester" })}>
打开详情
</button>
);
}

View File

@ -0,0 +1,584 @@
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 { issueAppUserAccessToken } from "@/features/app-users/api";
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", "状态记录"],
["device", "设备"],
];
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}
{activeTab === "device" ? <DevicePanel 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="最新登录 IP" value={<LatestLoginIpValue user={user} />} wide />
<DetailItem label="当前 App 版本" value={formatAppVersion(user.appVersion, user.buildNumber)} />
<DetailItem label="OpenID" value={<OpenIdListValue identities={user.thirdPartyIdentities} />} wide />
<DetailItem label="当前 Token" value={<AccessTokenValue userId={user.userId} />} wide />
<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 DevicePanel({ user }) {
const devices = user.devices || [];
return (
<>
<DetailSection title={`当前设备 (${devices.length})`}>
{devices.length ? (
devices.map((device) => (
<div className={styles.detailGrid} key={device.deviceId}>
<DetailItem
label="设备型号"
value={[device.manufacturer, device.deviceModel].filter(Boolean).join(" ")}
/>
{/* iOS 设备也会上报,所以统一叫“系统版本”而不是“安卓版本”。 */}
<DetailItem label="系统版本" value={device.osVersion} />
<DetailItem label="IMEI" value={device.imei} />
<DetailItem
label="设备 ID"
value={<CopyableValue label="复制设备 ID" value={device.deviceId} />}
/>
<DetailItem
label="App 版本"
value={formatAppVersion(device.appVersion, device.buildNumber)}
/>
<DetailItem label="最近活跃" value={formatMillis(device.lastSeenAtMs)} />
<DetailItem label="首次记录" value={formatMillis(device.firstSeenAtMs)} />
</div>
))
) : (
<div className={styles.emptyInline}>暂无设备上报数据需要 App 携带设备信息的版本</div>
)}
</DetailSection>
<DetailSection title="注册设备">
<div className={styles.detailGrid}>
<DetailItem label="注册设备" value={user.registerDevice} />
<DetailItem label="注册系统版本" value={user.registerOsVersion} />
<DetailItem
label="注册设备 ID"
value={<CopyableValue label="复制注册设备 ID" value={user.registerDeviceId} />}
/>
</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({ display, label, value }) {
const [copied, setCopied] = useState(false);
const normalized = String(value ?? "").trim();
if (!normalized) {
return "-";
}
const copy = async () => {
// display Token
await copyText(normalized);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<span className={styles.copyableDetailValue}>
<span>{display || normalized}</span>
<button aria-label={label} className={styles.copyDetailButton} title={copied ? "已复制" : label} type="button" onClick={copy}>
<ContentCopyOutlined fontSize="inherit" />
</button>
</span>
);
}
function LatestLoginIpValue({ user }) {
if (!user.lastLoginIp) {
return "-";
}
// IP IP 便
const suffix = [
user.lastLoginIpCountryCode,
user.lastLoginIpAtMs ? `记录于 ${formatMillis(user.lastLoginIpAtMs)}` : "",
]
.filter(Boolean)
.join(" · ");
return (
<span className={styles.copyableDetailValue}>
<CopyableValue label="复制登录 IP" value={user.lastLoginIp} />
{suffix ? <span>{suffix}</span> : null}
</span>
);
}
function OpenIdListValue({ identities }) {
const list = identities || [];
if (!list.length) {
return "-";
}
// provider openid
return (
<span>
{list.map((identity) => (
<span className={styles.copyableDetailValue} key={`${identity.provider}-${identity.openid}`}>
<span>{identity.provider || "-"}</span>
<span>·</span>
<CopyableValue label="复制 OpenID" value={identity.openid} />
</span>
))}
</span>
);
}
function AccessTokenValue({ userId }) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [token, setToken] = useState(null);
useEffect(() => {
// Token
setError("");
setLoading(false);
setToken(null);
}, [userId]);
const fetchToken = async () => {
setError("");
setLoading(true);
try {
setToken(await issueAppUserAccessToken(userId));
} catch (issueError) {
//
setToken(null);
setError(issueError instanceof Error && issueError.message ? issueError.message : "获取 Token 失败");
} finally {
setLoading(false);
}
};
if (token) {
return (
<span className={styles.copyableDetailValue}>
<CopyableValue
display={truncateToken(token.accessToken)}
label="复制完整 Token"
value={token.accessToken}
/>
{token.expiresInSec > 0 ? <span>有效期 {token.expiresInSec} </span> : null}
</span>
);
}
return (
<span className={styles.copyableDetailValue}>
{/* Token Tab
详情视图没有权限上下文useAuth 依赖 AuthProvider抽屉/详情页测试均不包裹
所以不在前端隐藏按钮 app-user:token 权限时后端返回 403错误文案原样透出 */}
<Button disabled={loading} onClick={fetchToken}>
{loading ? "获取中..." : "获取 Token"}
</Button>
{error ? <span>{error}</span> : null}
</span>
);
}
function truncateToken(value) {
const normalized = String(value || "");
// JWT 便
return normalized.length > 28 ? `${normalized.slice(0, 14)}${normalized.slice(-10)}` : normalized;
}
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 formatAppVersion(appVersion, buildNumber) {
if (!appVersion) {
return "-";
}
return buildNumber ? `${appVersion} (build ${buildNumber})` : appVersion;
}
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

@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/app-users/app-users.module.css";
const roleLabels = {
admin: "管理员",
bd: "BD",
bd_leader: "BD Leader",
bdleader: "BD Leader",
coin_seller: "币商",
guild_leader: "公会长",
host: "主播",
manager: "经理",
normal: "普通用户",
ordinary: "普通用户",
};
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 "-";
}
const displayLevel = numericLevel(level.displayLevel ?? level.realLevel);
const realLevel = numericLevel(level.realLevel);
const temporary = Number(level.expiresAtMs || 0) > 0 && Number(level.temporaryTargetLevel || 0) > 0;
return (
<div className={styles.stack}>
<span className={styles.levelPrimary}>Lv{displayLevel}</span>
{showReal || temporary ? (
<span className={styles.meta}>
{showReal ? `真实 Lv${realLevel}` : ""}
{showReal && temporary ? " · " : ""}
{temporary ? `临时至 ${formatMillis(level.expiresAtMs)}` : ""}
</span>
) : null}
</div>
);
}
export function RolesValue({ roles }) {
const labels = normalizeRoles(roles);
return <span className={styles.rolesValue}>{labels.join("、")}</span>;
}
export function OperatorValue({ operatedAtMs, operator }) {
if (!operator?.name && !operator?.adminId && !operatedAtMs) {
return "-";
}
const name = operator?.name || (operator?.adminId ? `管理员 ${operator.adminId}` : "-");
return (
<div className={styles.stack}>
<span>{name}</span>
<span className={styles.meta}>
{[operator?.action, formatMillis(operatedAtMs)].filter((value) => value && value !== "-").join(" · ") || "-"}
</span>
</div>
);
}
export function BanCountdown({ ban, status }) {
const [now, setNow] = useState(0);
const banned = status === "banned" || status === "disabled" || Boolean(ban?.active);
const expiresAtMs = Number(ban?.expiresAtMs || 0);
useEffect(() => {
if (!banned || ban?.permanent || expiresAtMs <= 0) {
return undefined;
}
const startupTimer = window.setTimeout(() => setNow(Date.now()), 0);
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => {
window.clearTimeout(startupTimer);
window.clearInterval(timer);
};
}, [ban?.permanent, banned, expiresAtMs]);
const label = useMemo(() => {
if (!banned) {
return "-";
}
// banned/disabled
if (ban?.permanent || expiresAtMs <= 0) {
return "永久";
}
if (now <= 0) {
return `${formatMillis(expiresAtMs)}`;
}
const remainingMs = expiresAtMs - now;
if (remainingMs <= 0) {
return "等待自动解封";
}
return formatDuration(remainingMs);
}, [ban?.permanent, banned, expiresAtMs, now]);
return <span className={banned ? styles.banCountdown : styles.meta}>{label}</span>;
}
export function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
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)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { currency: "USD", style: "currency" }).format(amount / 100);
}
export function normalizeRoles(roles) {
const values = Array.isArray(roles) ? roles.filter(Boolean) : [];
if (!values.length) {
return ["普通用户"];
}
return Array.from(new Set(values.map((role) => roleLabels[String(role).toLowerCase()] || String(role))));
}
function numericLevel(value) {
const level = Number(value || 0);
return Number.isFinite(level) ? Math.max(0, Math.trunc(level)) : 0;
}
function formatDuration(milliseconds) {
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (days > 0) {
return `${days}${hours}小时`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}`;
}
if (minutes > 0) {
return `${minutes}${seconds}`;
}
return `${seconds}`;
}

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { downloadResponse } from "@/shared/api/download";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
@ -6,14 +7,24 @@ import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
import {
adjustAppUserLevels,
banAppUser,
createAppUserExport,
downloadAppUserExport,
getAppUserExportJob,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import {
appUserBanSchema,
appUserCountrySchema,
appUserLevelAdjustmentSchema,
appUserPasswordSchema,
appUserUpdateSchema,
} from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
const pageSize = 50;
@ -21,6 +32,7 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
const emptyCountryForm = () => ({ country: "" });
const emptyPasswordForm = () => ({ password: "" });
const emptyBanForm = () => ({ expiresAtLocal: futureDateTimeLocal(7), mode: "permanent", reason: "" });
export function useAppUsersPage() {
const abilities = useAppUserAbilities();
@ -41,6 +53,8 @@ export function useAppUsersPage() {
const [editForm, setEditForm] = useState(emptyEditForm);
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [levelForm, setLevelForm] = useState(() => levelFormFromUser(null));
const [banForm, setBanForm] = useState(emptyBanForm);
const [loadingAction, setLoadingAction] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState([]);
const [patchedUsers, setPatchedUsers] = useState({});
@ -141,6 +155,8 @@ export function useAppUsersPage() {
setRegionId("");
setStatus("");
setTimeRange({ endMs: "", startMs: "" });
setSortBy("created_at");
setSortDirection("desc");
setPage(1);
};
@ -151,6 +167,7 @@ export function useAppUsersPage() {
gender: user.gender || "",
username: user.username || "",
});
setLevelForm(levelFormFromUser(user));
setActiveAction("edit");
};
@ -183,9 +200,30 @@ export function useAppUsersPage() {
}
await runAction("edit", "用户已更新", async () => {
const payload = parseForm(appUserUpdateSchema, editForm);
await updateAppUser(activeUser.userId, payload);
closeAction();
await reload();
const result = await updateAppUser(activeUser.userId, payload);
patchUsers([result]);
setActiveUser((current) => (current ? { ...current, ...result } : result));
});
};
const submitLevels = async () => {
if (!activeUser) {
return;
}
await runAction("levels", "等级调整已提交", async () => {
const form = parseForm(appUserLevelAdjustmentSchema, levelForm);
// 两条轨道只发一个 adjustments 数组,保证跨轨道由 activity-service 同事务提交或整体失败。
const adjustments = ["wealth", "charm"]
.filter((track) => form[track].enabled)
.map((track) => ({
duration_days: Number(form[track].durationDays),
level: Number(form[track].level),
track,
}));
const result = await adjustAppUserLevels(activeUser.userId, { adjustments, reason: form.reason });
patchUsers([result]);
setActiveUser((current) => (current ? { ...current, ...result } : result));
setLevelForm(levelFormFromUser(result));
});
};
@ -216,13 +254,45 @@ export function useAppUsersPage() {
const toggleBan = async (user) => {
const banned = isBanned(user);
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
if (!banned) {
setActiveUser(user);
setBanForm(emptyBanForm());
setActiveAction("ban");
return;
}
const ok = await confirm({
confirmText: "解封",
message: "仅当该用户不存在其他有效管理员或经理封禁时,账号状态才会恢复。",
title: "解封用户",
});
if (!ok) {
return;
}
await runAction(`status-${user.userId}`, "用户已解封", async () => {
const result = await unbanAppUser(user.userId, { reason: "admin_manual_unban" });
patchUsers([mergeUserStatus(user, result, "active")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
});
};
const submitBan = async (event) => {
event.preventDefault();
if (!activeUser) {
return;
}
await runAction(`status-${activeUser.userId}`, "用户已封禁", async () => {
const form = parseForm(appUserBanSchema, banForm);
const expiresAtMs = form.mode === "permanent" ? 0 : new Date(form.expiresAtLocal).getTime();
const result = await banAppUser(activeUser.userId, {
expires_at_ms: expiresAtMs,
reason: form.reason,
});
patchUsers([mergeUserStatus(activeUser, result, "banned")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== activeUser.userId));
closeAction();
});
};
const batchBan = async () => {
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
if (!users.length) {
@ -231,7 +301,7 @@ export function useAppUsersPage() {
}
const ok = await confirm({
confirmText: "封禁",
message: `封禁 ${users.length} 个用户。`,
message: `永久封禁 ${users.length} 个用户。`,
title: "批量封禁用户",
tone: "danger",
});
@ -241,7 +311,11 @@ export function useAppUsersPage() {
setLoadingAction("batch-ban");
try {
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
const results = await Promise.allSettled(
users.map((user) =>
banAppUser(user.userId, { expires_at_ms: 0, reason: "admin_batch_permanent_ban" }),
),
);
const updatedUsers = results
.map((result, index) =>
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
@ -270,6 +344,23 @@ export function useAppUsersPage() {
}
};
const exportUsers = async () => {
setLoadingAction("export");
try {
// 创建任务时仅传当前筛选快照;页码由后端固定为首批并逐页导出,避免只导出当前可见页。
const created = await createAppUserExport(filters);
showToast(`导出任务 #${created.jobId} 已创建`, "success");
const job = await waitAppUserExportJob(created.jobId);
const response = await downloadAppUserExport(created.jobId);
await downloadResponse(response, appUserExportFilename(created.snapshotAtMs || job.createdAtMs));
showToast("用户 CSV 已生成", "success");
} catch (err) {
showToast(err.message || "导出用户失败", "error");
} finally {
setLoadingAction("");
}
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
@ -299,6 +390,7 @@ export function useAppUsersPage() {
abilities,
activeAction,
activeUser,
banForm,
batchBan,
changeUserFilter,
changeStatus,
@ -313,6 +405,7 @@ export function useAppUsersPage() {
loadingAction,
loadingCountries,
loadingRegions,
levelForm,
locationFilterOptions,
locationFilterValue,
openCountry,
@ -327,7 +420,9 @@ export function useAppUsersPage() {
resetFilters,
selectedUserIds,
setCountryForm,
setBanForm,
setEditForm,
setLevelForm,
setPage,
setPasswordForm,
setSelectedUserIds,
@ -339,9 +434,12 @@ export function useAppUsersPage() {
changeSort,
changeTimeRange,
submitCountry,
submitBan,
submitEdit,
submitLevels,
submitPassword,
toggleBan,
exportUsers,
};
}
@ -356,3 +454,56 @@ function mergeUserStatus(currentUser, result, fallbackStatus) {
status: result?.status || fallbackStatus,
};
}
function levelFormFromUser(user) {
return {
charm: levelTrackForm(user?.levels?.charm),
reason: "",
wealth: levelTrackForm(user?.levels?.wealth),
};
}
function levelTrackForm(level) {
const realLevel = Number(level?.realLevel || 0);
const currentTarget = Number(level?.temporaryTargetLevel || level?.displayLevel || realLevel + 1);
const expiresAtMs = Number(level?.expiresAtMs || 0);
const remainingDays = expiresAtMs > Date.now() ? Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 86400000)) : 7;
return {
durationDays: String(remainingDays),
enabled: false,
level: String(Math.min(50, Math.max(1, currentTarget))),
};
}
async function waitAppUserExportJob(jobId) {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const job = await getAppUserExportJob(jobId);
if (job.status === "succeeded") {
return job;
}
if (["failed", "canceled", "cancelled"].includes(job.status)) {
throw new Error(job.error || "用户导出任务失败");
}
await delay(1500);
}
throw new Error("用户导出任务仍在处理中,请稍后重试");
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function futureDateTimeLocal(days) {
const date = new Date(Date.now() + days * 86400000);
const offsetMs = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function appUserExportFilename(snapshotAtMs) {
const date = new Date(Number(snapshotAtMs || Date.now()));
const stamp = Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "snapshot";
return `app-users-${stamp}.csv`;
}

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,57 @@
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";
// AppUserDetailView issueAppUserAccessTokenmock
vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: 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

@ -1,12 +1,15 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
@ -22,6 +25,13 @@ import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
import {
BanCountdown,
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";
import styles from "@/features/app-users/app-users.module.css";
@ -82,12 +92,30 @@ 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: "levels",
label: "财富/魅力/游戏等级",
width: "minmax(230px, 1.15fr)",
render: (user) => <UserLevelsValue levels={user.levels} />,
},
{
key: "roles",
label: "身份",
width: "minmax(150px, 0.9fr)",
render: (user) => <RolesValue roles={user.roles} />,
},
{
key: "vip",
@ -134,7 +162,30 @@ export function AppUserListPage() {
value: page.status,
onChange: page.changeStatus,
}),
render: (user) => <UserStatus status={user.status} />,
render: (user) => (
<div className={styles.stack}>
<UserStatus status={user.status} />
<BanCountdown ban={user.ban} status={user.status} />
</div>
),
},
{
key: "lastLoginAtMs",
label: "最新成功登录",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastLoginAtMs),
},
{
key: "lastOperator",
label: "最新操作人",
width: "minmax(142px, 0.8fr)",
render: (user) => <OperatorValue operator={user.lastOperator} />,
},
{
key: "lastOperatedAtMs",
label: "最新操作时间",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastOperatedAtMs),
},
{
key: "time",
@ -181,6 +232,19 @@ export function AppUserListPage() {
onChange={page.changeTimeRange}
/>
</div>
<div className={styles.toolbarActions}>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
重置筛选
</Button>
{page.abilities.canExport ? (
<Button
disabled={page.loadingAction === "export"}
startIcon={<FileDownloadOutlined fontSize="small" />}
onClick={page.exportUsers}
>
{page.loadingAction === "export" ? "导出中" : "导出 CSV"}
</Button>
) : null}
{page.abilities.canStatus ? (
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
@ -192,10 +256,11 @@ export function AppUserListPage() {
</Button>
) : null}
</div>
</div>
<DataTable
columns={tableColumns}
items={items}
minWidth="1220px"
minWidth="2380px"
pagination={
total > 0
? {
@ -212,15 +277,17 @@ export function AppUserListPage() {
</DataState>
</div>
<ActionModal
disabled={!page.abilities.canUpdate}
<AdminFormDialog
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
sectionTitle="用户资料"
size="standard"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
submitLabel="保存资料"
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.submitEdit}
onSubmit={page.abilities.canUpdate ? page.submitEdit : (event) => event.preventDefault()}
>
<AdminFormSection title="用户资料">
<TextField
disabled={!page.abilities.canUpdate}
label="用户名称"
@ -246,6 +313,60 @@ export function AppUserListPage() {
</MenuItem>
))}
</TextField>
</AdminFormSection>
<AdminFormSection
actions={
<Button
disabled={!page.abilities.canLevel || page.loadingAction === "levels"}
variant="primary"
onClick={page.submitLevels}
>
{page.loadingAction === "levels" ? "保存中" : "保存等级"}
</Button>
}
title="等级调整"
>
<LevelAdjustmentFields page={page} />
</AdminFormSection>
</AdminFormDialog>
<ActionModal
disabled={!page.abilities.canStatus}
loading={page.loadingAction === `status-${page.activeUser?.userId}`}
open={page.activeAction === "ban"}
sectionTitle="封禁信息"
title="封禁用户"
onClose={page.closeAction}
onSubmit={page.submitBan}
>
<TextField
disabled={!page.abilities.canStatus}
label="封禁期限"
select
value={page.banForm.mode}
onChange={(event) => page.setBanForm({ ...page.banForm, mode: event.target.value })}
>
<MenuItem value="permanent">永久</MenuItem>
<MenuItem value="timed">指定截止时间</MenuItem>
</TextField>
{page.banForm.mode === "timed" ? (
<TextField
disabled={!page.abilities.canStatus}
label="封禁截止时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={page.banForm.expiresAtLocal}
onChange={(event) => page.setBanForm({ ...page.banForm, expiresAtLocal: event.target.value })}
/>
) : null}
<TextField
disabled={!page.abilities.canStatus}
label="封禁原因"
multiline
minRows={2}
value={page.banForm.reason}
onChange={(event) => page.setBanForm({ ...page.banForm, reason: event.target.value })}
/>
</ActionModal>
<ActionModal
@ -321,6 +442,70 @@ function CoinValue({ page, user }) {
);
}
function LevelAdjustmentFields({ page }) {
const tracks = [
["wealth", "富豪等级"],
["charm", "魅力等级"],
];
const updateTrack = (track, patch) => {
page.setLevelForm((current) => ({
...current,
[track]: { ...current[track], ...patch },
}));
};
return (
<div className={styles.levelForm}>
{tracks.map(([track, label]) => {
const field = page.levelForm[track];
const current = page.activeUser?.levels?.[track];
return (
<div className={styles.levelTrackForm} key={track}>
<FormControlLabel
control={
<Checkbox
checked={field.enabled}
disabled={!page.abilities.canLevel}
onChange={(event) => updateTrack(track, { enabled: event.target.checked })}
/>
}
label={`${label}(真实 Lv${Number(current?.realLevel || 0)},展示 Lv${Number(current?.displayLevel || 0)}`}
/>
<div className={styles.formGridTwo}>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="目标等级"
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
type="number"
value={field.level}
onChange={(event) => updateTrack(track, { level: event.target.value })}
/>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="有效天数"
slotProps={{ htmlInput: { max: 36500, min: 1, step: 1 } }}
type="number"
value={field.durationDays}
onChange={(event) => updateTrack(track, { durationDays: event.target.value })}
/>
</div>
</div>
);
})}
<TextField
disabled
label="游戏等级(只读)"
value={`Lv${Number(page.activeUser?.levels?.game?.displayLevel || 0)}`}
/>
<TextField
disabled={!page.abilities.canLevel}
label="调整原因"
value={page.levelForm.reason}
onChange={(event) => page.setLevelForm((current) => ({ ...current, reason: event.target.value }))}
/>
</div>
);
}
function VipValue({ vip }) {
const level = Number(vip?.level || 0);
if (level <= 0 || !vip?.active) {
@ -350,10 +535,13 @@ function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
<div className={styles.rowActions}>
{page.abilities.canUpdate ? (
<Tooltip arrow title="编辑">
{page.abilities.canUpdate || page.abilities.canLevel ? (
<Tooltip arrow title={page.abilities.canUpdate ? "编辑" : "等级调整"}>
<span>
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
<IconButton
label={page.abilities.canUpdate ? "编辑" : "等级调整"}
onClick={() => page.openEdit(user)}
>
<EditOutlined fontSize="small" />
</IconButton>
</span>

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { AppUserListPage } from "./AppUserListPage.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
const mocks = vi.hoisted(() => ({
@ -26,9 +27,19 @@ afterEach(() => {
test("clicking an app user's coin opens the coin ledger drawer", () => {
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
render(<AppUserListPage />);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByText("VIP 2")).toBeInTheDocument();
expect(screen.getByText("VIP888")).toBeInTheDocument();
expect(screen.getByText("公会长、BD")).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 的金币流水" }));
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
@ -43,7 +54,11 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
}),
);
render(<AppUserListPage />);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(
@ -51,10 +66,49 @@ test("coin ledger drawer passes the active app user to the shared ledger table",
);
});
test("edit dialog saves profile and temporary levels independently", () => {
const page = pageFixture({ activeAction: "edit", activeUser: userFixture() });
vi.mocked(useAppUsersPage).mockReturnValue(page);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
expect(screen.getByText("用户资料")).toBeInTheDocument();
expect(screen.getByText("等级调整")).toBeInTheDocument();
expect(screen.getByLabelText("游戏等级(只读)")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "保存等级" }));
expect(page.submitLevels).toHaveBeenCalledTimes(1);
fireEvent.submit(screen.getByRole("button", { name: "保存资料" }).closest("form"));
expect(page.submitEdit).toHaveBeenCalledTimes(1);
});
test("level-only permission can open level adjustment without exposing country edit", () => {
const page = pageFixture({
abilities: { ...pageFixture().abilities, canLevel: true, canUpdate: false },
});
vi.mocked(useAppUsersPage).mockReturnValue(page);
render(
<ToastProvider>
<AppUserListPage />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "等级调整" }));
expect(page.openEdit).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
expect(screen.queryByRole("button", { name: "阿拉伯联合酋长国" })).not.toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: {
canCoinLedger: true,
canExport: true,
canLevel: true,
canPassword: true,
canStatus: true,
canUpdate: true,
@ -63,6 +117,7 @@ function pageFixture(patch = {}) {
},
activeAction: "",
activeUser: null,
banForm: { expiresAtLocal: "", mode: "permanent", reason: "" },
batchBan: vi.fn(),
changeLocationFilter: vi.fn(),
changeQuery: vi.fn(),
@ -74,11 +129,17 @@ function pageFixture(patch = {}) {
countryOptions: [],
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { avatar: "", gender: "", username: "" },
exportUsers: vi.fn(),
error: null,
loading: false,
loadingAction: "",
loadingCountries: false,
loadingRegions: false,
levelForm: {
charm: { durationDays: "7", enabled: false, level: "4" },
reason: "",
wealth: { durationDays: "7", enabled: false, level: "5" },
},
locationFilterOptions: [],
locationFilterValue: "",
openCoinLedger: mocks.openCoinLedger,
@ -90,17 +151,22 @@ function pageFixture(patch = {}) {
query: "",
regionOptions: [],
reload: vi.fn(),
resetFilters: vi.fn(),
selectedUserIds: [],
setBanForm: vi.fn(),
setCountryForm: vi.fn(),
setEditForm: vi.fn(),
setLevelForm: vi.fn(),
setPage: vi.fn(),
setPasswordForm: vi.fn(),
setSelectedUserIds: vi.fn(),
sortBy: "created_at",
sortDirection: "desc",
status: "",
submitBan: vi.fn(),
submitCountry: vi.fn(),
submitEdit: vi.fn(),
submitLevels: vi.fn(),
submitPassword: vi.fn(),
timeRange: { endMs: "", startMs: "" },
toggleBan: vi.fn(),
@ -110,15 +176,30 @@ function pageFixture(patch = {}) {
function userFixture() {
return {
age: 24,
avatar: "",
ban: { active: false, expiresAtMs: 0, permanent: false },
coin: 223437,
country: "AE",
countryDisplayName: "阿拉伯联合酋长国",
createdAtMs: 1779322523000,
defaultDisplayUserId: "163337",
displayUserId: "163337",
gender: "female",
lastActiveAtMs: 1781325798000,
lastLoginAtMs: 1781325700000,
lastOperatedAtMs: 1781325750000,
lastOperator: { action: "update", adminId: "7", name: "雷乐" },
levels: {
charm: { displayLevel: 3, realLevel: 3, track: "charm" },
game: { displayLevel: 6, realLevel: 6, track: "game" },
wealth: { displayLevel: 4, realLevel: 4, track: "wealth" },
},
prettyDisplayUserId: "VIP888",
prettyId: "pretty-888",
regionId: 2,
regionName: "中东区",
roles: ["公会长", "BD"],
status: "active",
userId: "10001",
username: "d",

View File

@ -15,5 +15,7 @@ export function useAppUserAbilities() {
canUpdate: can(PERMISSIONS.appUserUpdate),
canUpload: can(PERMISSIONS.uploadCreate),
canView: can(PERMISSIONS.appUserView),
canLevel: can(PERMISSIONS.appUserLevel),
canExport: can(PERMISSIONS.appUserExport),
};
}

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,28 @@
import { describe, expect, test } from "vitest";
import { parseForm } from "@/shared/forms/validation";
import { prettyDisplayIDGrantSchema } from "./schema";
describe("pretty display ID grant schema", () => {
test("accepts arbitrary Unicode display content", () => {
// 管理端只保留展示文本的非空和长度约束,避免 Unicode 字体、符号和 emoji 被客户端正则错误拦截。
const payload = parseForm(prettyDisplayIDGrantSchema, {
displayUserId: " 𝕸⃝é中⚡️™ ",
durationDays: "1",
reason: "Unicode 靓号",
targetUserId: "163001",
});
expect(payload.displayUserId).toBe("𝕸⃝é中⚡️™");
});
test("counts supplementary Unicode characters as one character", () => {
const payload = parseForm(prettyDisplayIDGrantSchema, {
displayUserId: "😀".repeat(64),
durationDays: "",
reason: "",
targetUserId: "163001",
});
expect(Array.from(payload.displayUserId)).toHaveLength(64);
});
});

View File

@ -14,8 +14,64 @@ export const appUserPasswordSchema = z.object({
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
});
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记空格、标点、emoji 都在前端先拦截。
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
const temporaryLevelTrackSchema = z.object({
durationDays: z.string().trim().optional().default("7"),
enabled: z.boolean().optional().default(false),
level: z.string().trim().optional().default(""),
});
// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。
export const appUserLevelAdjustmentSchema = z
.object({
charm: temporaryLevelTrackSchema,
reason: z.string().trim().max(128, "调整原因不能超过 128 个字符").optional().default(""),
wealth: temporaryLevelTrackSchema,
})
.superRefine((value, context) => {
const enabledTracks = [
["wealth", value.wealth],
["charm", value.charm],
] as const;
if (!enabledTracks.some(([, track]) => track.enabled)) {
context.addIssue({ code: "custom", message: "至少选择一个等级轨道", path: ["wealth", "enabled"] });
return;
}
enabledTracks.forEach(([name, track]) => {
if (!track.enabled) {
return;
}
const level = Number(track.level);
if (!Number.isInteger(level) || level < 1 || level > 50) {
context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] });
}
const durationDays = Number(track.durationDays);
if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) {
context.addIssue({
code: "custom",
message: "有效天数必须是 1 到 36500 的整数",
path: [name, "durationDays"],
});
}
});
});
// expires_at_ms=0 仅由“永久”模式产生;指定截止时间必须在浏览器当前时刻之后,服务端仍以 UTC 再次校验。
export const appUserBanSchema = z
.object({
expiresAtLocal: z.string().trim().optional().default(""),
mode: z.enum(["permanent", "timed"], "请选择封禁期限"),
reason: z.string().trim().max(512, "封禁原因不能超过 512 个字符").optional().default(""),
})
.superRefine((value, context) => {
if (value.mode !== "timed") {
return;
}
const expiresAtMs = new Date(value.expiresAtLocal).getTime();
if (!value.expiresAtLocal || !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
context.addIssue({ code: "custom", message: "截止时间必须晚于当前时间", path: ["expiresAtLocal"] });
}
});
const prettyRuleValues = [
"aa",
"aaa",
@ -77,14 +133,16 @@ export const prettyDisplayIDGenerateSchema = z.object({
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
});
// 后台发放允许管理员输入非数字靓号durationDays 为空或 0 都会转成 duration_ms=0表示长期持有
// 靓号展示内容必须原样支持任意 Unicode 字符,不能以字母数字正则过滤字体字形、表情或符号;仅去除首尾空白、限制长度并拦截控制字符,服务端继续负责唯一性和持久化安全校验
export const prettyDisplayIDGrantSchema = z.object({
displayUserId: z
.string()
.trim()
.min(1, "请输入靓号内容")
.max(64, "靓号不能超过 64 个字符")
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
// String.length 会把补充平面 Unicode 字符计为两个 UTF-16 单元,必须按 code point 计数,才能和服务端的 rune 上限一致。
.refine((value) => Array.from(value).length <= 64, "靓号不能超过 64 个字符")
// 控制字符不是展示字形保留会破坏列表、日志和协议载荷符号、emoji 和格式化字形仍可通过。
.refine((value) => !/\p{Cc}/u.test(value), ""),
durationDays: z
.string()
.trim()
@ -111,6 +169,8 @@ export const prettyDisplayIDStatusSchema = z.object({
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
export type AppUserLevelAdjustmentForm = z.infer<typeof appUserLevelAdjustmentSchema>;
export type AppUserBanForm = z.infer<typeof appUserBanSchema>;
export type PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;

View File

@ -0,0 +1,74 @@
const redirectParam = "redirect";
const defaultRedirectTarget = "/overview";
const documentEntryPrefixes = ["/finance", "/databi", "/ops-center", "/money"];
export function locationToPath(location) {
if (!location?.pathname) {
return "";
}
return `${location.pathname}${location.search || ""}${location.hash || ""}`;
}
export function buildLoginRedirectPath(location) {
const target = normalizeRedirectTarget(locationToPath(location));
if (!target) {
return "/login";
}
return `/login?${redirectParam}=${encodeURIComponent(target)}`;
}
export function resolveLoginRedirectTarget(location, fallback = defaultRedirectTarget) {
const queryTarget = new URLSearchParams(location?.search || "").get(redirectParam);
return normalizeRedirectTarget(queryTarget) || normalizeRedirectTarget(locationToPath(location?.state?.from)) || fallback;
}
export function isDocumentEntryTarget(target) {
const normalized = normalizeRedirectTarget(target);
if (!normalized) {
return false;
}
const { pathname } = new URL(normalized, currentOrigin());
return documentEntryPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
export function redirectAfterLogin(target, navigate, targetWindow = window) {
const normalized = normalizeRedirectTarget(target) || defaultRedirectTarget;
if (isDocumentEntryTarget(normalized)) {
// Subsystems are separate HTML entries; SPA navigation would hit the admin fallback route.
targetWindow.location.replace(normalized);
return;
}
navigate(normalized, { replace: true });
}
function normalizeRedirectTarget(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) {
return "";
}
let url;
try {
url = new URL(raw, currentOrigin());
} catch {
return "";
}
if (url.origin !== currentOrigin()) {
return "";
}
const target = `${url.pathname}${url.search}${url.hash}`;
if (!target.startsWith("/") || isLoginTarget(target)) {
return "";
}
return target;
}
function isLoginTarget(target) {
return new URL(target, currentOrigin()).pathname === "/login";
}
function currentOrigin() {
return globalThis.window?.location?.origin || "http://localhost";
}

View File

@ -0,0 +1,67 @@
import { expect, test, vi } from "vitest";
import {
buildLoginRedirectPath,
isDocumentEntryTarget,
locationToPath,
redirectAfterLogin,
resolveLoginRedirectTarget
} from "./loginRedirect.js";
test("builds login path with the full current path", () => {
const loginPath = buildLoginRedirectPath({
hash: "#summary",
pathname: "/finance/",
search: "?view=rechargeDetails&app=lalu"
});
expect(loginPath).toBe("/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails%26app%3Dlalu%23summary");
});
test("resolves login redirect from query before router state", () => {
const target = resolveLoginRedirectTarget({
pathname: "/login",
search: "?redirect=%2Ffinance%2F%3Fview%3Drecon",
state: { from: { pathname: "/system/users", search: "?page=2" } }
});
expect(target).toBe("/finance/?view=recon");
});
test("falls back to router state and preserves search and hash", () => {
const target = resolveLoginRedirectTarget({
pathname: "/login",
search: "",
state: { from: { hash: "#role", pathname: "/system/users", search: "?page=2" } }
});
expect(target).toBe("/system/users?page=2#role");
});
test("rejects unsafe or looping redirect targets", () => {
expect(resolveLoginRedirectTarget({ search: "?redirect=https%3A%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
expect(resolveLoginRedirectTarget({ search: "?redirect=%2F%2Fevil.example%2Ffinance%2F" })).toBe("/overview");
expect(resolveLoginRedirectTarget({ search: "?redirect=%2Flogin%3Fredirect%3D%252Ffinance%252F" })).toBe("/overview");
});
test("detects document entry targets", () => {
expect(isDocumentEntryTarget("/finance/?view=overview")).toBe(true);
expect(isDocumentEntryTarget("/databi/social/?view=funnel")).toBe(true);
expect(isDocumentEntryTarget("/ops-center/")).toBe(true);
expect(isDocumentEntryTarget("/overview")).toBe(false);
});
test("redirectAfterLogin reloads document entries and uses router navigation for admin routes", () => {
const navigate = vi.fn();
const targetWindow = { location: { replace: vi.fn() } };
redirectAfterLogin("/finance/?view=rechargeDetails", navigate, targetWindow);
expect(targetWindow.location.replace).toHaveBeenCalledWith("/finance/?view=rechargeDetails");
expect(navigate).not.toHaveBeenCalled();
redirectAfterLogin("/system/users?page=2", navigate, targetWindow);
expect(navigate).toHaveBeenCalledWith("/system/users?page=2", { replace: true });
});
test("locationToPath returns an empty string for missing location", () => {
expect(locationToPath(null)).toBe("");
});

View File

@ -1,9 +1,10 @@
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LoginOutlined from "@mui/icons-material/LoginOutlined";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { isDocumentEntryTarget, redirectAfterLogin, resolveLoginRedirectTarget } from "@/features/auth/loginRedirect.js";
import { Button } from "@/shared/ui/Button.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -15,9 +16,19 @@ export function LoginPage() {
const { showToast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/overview";
const from = resolveLoginRedirectTarget(location);
const needsDocumentRedirect = isDocumentEntryTarget(from);
useEffect(() => {
if (user && needsDocumentRedirect) {
redirectAfterLogin(from, navigate);
}
}, [from, navigate, needsDocumentRedirect, user]);
if (user) {
if (needsDocumentRedirect) {
return null;
}
return <Navigate to={from} replace />;
}
@ -27,7 +38,7 @@ export function LoginPage() {
try {
await login({ username, password });
showToast("登录成功", "success");
navigate(from, { replace: true });
redirectAfterLogin(from, navigate);
} catch (err) {
showToast(err.message || "登录失败", "error");
} finally {

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,46 +0,0 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getFrequentSecondLevelMenus } from "@/app/navigation/menuUsage.js";
import { TimeText } from "@/shared/ui/TimeText.jsx";
export function DashboardFrequentMenus({ menus = [] }) {
const { user } = useAuth();
const navigate = useNavigate();
const frequentMenus = useMemo(() => getFrequentSecondLevelMenus({ menus, user }), [menus, user]);
return (
<section className="frequent-menu-section" aria-label="常用二级菜单">
<div className="frequent-menu-head">
<div>
<h2>常用页面</h2>
<p>本地缓存的二级菜单访问次数</p>
</div>
</div>
{frequentMenus.length ? (
<div className="frequent-menu-grid">
{frequentMenus.map((item) => {
const Icon = item.icon;
return (
<button
className="frequent-menu-card"
key={item.code}
type="button"
onClick={() => navigate(item.path)}
>
<span className="frequent-menu-card__icon">{Icon ? <Icon fontSize="small" /> : null}</span>
<span className="frequent-menu-card__group">{item.parentLabel}</span>
<strong>{item.label}</strong>
<span className="frequent-menu-card__meta">
进入 {item.count || 0} · <TimeText value={item.lastVisitedAtMs} />
</span>
</button>
);
})}
</div>
) : (
<div className="frequent-menu-empty">暂无本地访问记录</div>
)}
</section>
);
}

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

@ -8,7 +8,7 @@ export function DashboardToolbar({ onRefresh, onRangeChange, overview, range })
const { formatMillis } = useTimeZone();
return (
<PageHead title="系统概览" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
<PageHead title="工作台" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
<TimeRangeSelect value={range} onChange={onRangeChange} />
<Button onClick={onRefresh}>
<Refresh fontSize="small" />

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

@ -0,0 +1,58 @@
import AccountBalanceOutlined from "@mui/icons-material/AccountBalanceOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export const WORKSPACE_ENTRIES = [
{
description: "财务管理",
href: "/finance/",
icon: AccountBalanceOutlined,
label: "财务工作台",
permission: PERMISSIONS.financeView,
},
{
description: "运营管理",
href: "/databi/social/",
icon: InsightsOutlined,
label: "运营工作台",
permission: PERMISSIONS.overviewView,
},
];
export function getPermittedWorkspaceEntries(can) {
// HTML
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission));
}
export function DashboardWorkspaces() {
const { can } = useAuth();
const entries = getPermittedWorkspaceEntries(can);
return (
<section className="workspace-entry-section" aria-label="工作台入口">
<div className="workspace-entry-head">
<h2>工作台</h2>
</div>
{entries.length ? (
<div className="workspace-entry-grid">
{entries.map((entry) => {
const Icon = entry.icon;
return (
// 使 Vite HTML
<a className="workspace-entry-card" href={entry.href} key={entry.href}>
<span className="workspace-entry-card__icon">
<Icon fontSize="small" />
</span>
<span className="workspace-entry-card__group">{entry.description}</span>
<strong>{entry.label}</strong>
</a>
);
})}
</div>
) : (
<div className="workspace-entry-empty">当前无可访问工作台</div>
)}
</section>
);
}

View File

@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions";
import { DashboardWorkspaces } from "./DashboardWorkspaces.jsx";
const can = vi.fn();
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
useAuth: () => ({ can }),
}));
describe("DashboardWorkspaces", () => {
beforeEach(() => {
can.mockReset();
});
test("shows each workspace only when its existing permission is granted", () => {
can.mockImplementation((permission) => permission === PERMISSIONS.financeView);
render(<DashboardWorkspaces />);
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
expect(screen.queryByRole("link", { name: /运营工作台/ })).not.toBeInTheDocument();
});
test("routes the operations workspace to the Social BI document entry", () => {
can.mockImplementation((permission) => permission === PERMISSIONS.overviewView);
render(<DashboardWorkspaces />);
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
});
});

View File

@ -1,44 +1,181 @@
.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);
}
.frequent-menu-section {
margin-top: var(--space-4);
.dashboard-profile-section {
margin-top: var(--space-8);
padding-bottom: var(--space-4);
}
.frequent-menu-head {
.dashboard-profile-head {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.frequent-menu-head h2 {
.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);
}
.workspace-entry-head {
display: flex;
margin-bottom: var(--space-3);
}
.workspace-entry-head h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size-lg);
font-weight: 750;
}
.frequent-menu-head p {
margin: var(--space-1) 0 0;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.frequent-menu-grid {
.workspace-entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: var(--space-3);
}
.frequent-menu-card {
.workspace-entry-card {
display: grid;
min-height: 112px;
min-height: 128px;
align-content: start;
gap: var(--space-1);
padding: var(--space-4);
@ -48,6 +185,7 @@
color: var(--text-secondary);
cursor: pointer;
font: inherit;
text-decoration: none;
text-align: left;
transition:
border-color var(--motion-base) var(--ease-standard),
@ -55,13 +193,19 @@
transform var(--motion-base) var(--ease-emphasized);
}
.frequent-menu-card:hover {
.workspace-entry-card:hover,
.workspace-entry-card:focus-visible {
border-color: var(--primary-border-strong);
box-shadow: var(--shadow-panel);
transform: translateY(-2px);
}
.frequent-menu-card__icon {
.workspace-entry-card:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.workspace-entry-card__icon {
display: inline-flex;
width: 32px;
height: 32px;
@ -72,13 +216,13 @@
color: var(--primary);
}
.frequent-menu-card__group {
.workspace-entry-card__group {
margin-top: var(--space-1);
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-card strong {
.workspace-entry-card strong {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
@ -88,12 +232,7 @@
white-space: nowrap;
}
.frequent-menu-card__meta {
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-empty {
.workspace-entry-empty {
display: flex;
min-height: 88px;
align-items: center;
@ -457,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,8 +1,11 @@
import { useOutletContext } from "react-router-dom";
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
import { DashboardUserProfile } from "@/features/dashboard/components/DashboardUserProfile.jsx";
export function OverviewPage() {
const { menus = [] } = useOutletContext() || {};
return <DashboardFrequentMenus menus={menus} />;
return (
<>
<DashboardWorkspaces />
<DashboardUserProfile />
</>
);
}

View File

@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const dashboardRoutes = [
{
label: "系统概览",
label: "工作台",
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
menuCode: MENU_CODES.overview,
pageKey: "overview",

View File

@ -13,12 +13,18 @@ import {
disableRegion,
listAgencies,
listBDs,
listCoinSellerSubApplications,
listCoinSellerSubSellers,
listCoinSellers,
listCountries,
listHosts,
listManagers,
listRegions,
getJob,
renameCountryCode,
replaceRegionCountries,
approveCoinSellerSubApplication,
rejectCoinSellerSubApplication,
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
@ -62,8 +68,16 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" });
await createManager({
canManageCrossRegion: false,
canBlockUser: false,
canGrantBadge: false,
canGrantVip: true,
contact: "+63",
targetUserId: "1003",
});
await updateManager("1003", {
canManageCrossRegion: true,
canBlockUser: false,
canGrantVehicle: true,
canGrantVip: false,
@ -146,6 +160,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
expect(managerCreateInit?.method).toBe("POST");
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canManageCrossRegion: false,
canBlockUser: false,
canGrantBadge: false,
canGrantVip: true,
@ -155,6 +170,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
expect(managerUpdateInit?.method).toBe("PUT");
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canManageCrossRegion: true,
canBlockUser: false,
canGrantVehicle: true,
canGrantVip: false,
@ -198,6 +214,54 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
});
test("coin seller sub application APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })),
),
);
await listCoinSellerSubSellers("3001", { page: 1, page_size: 10 });
await listCoinSellerSubApplications({
keyword: "163001",
page: 2,
page_size: 10,
parent_user_id: "3001",
status: "pending",
target_user_id: "3002",
});
await approveCoinSellerSubApplication("sub-app-1", {
commandId: "approve-sub-app-test",
reason: "ok",
});
await rejectCoinSellerSubApplication("sub-app-2", {
commandId: "reject-sub-app-test",
reason: "bad",
});
const [subSellerUrl, subSellerInit] = vi.mocked(fetch).mock.calls[0];
const [applicationUrl, applicationInit] = vi.mocked(fetch).mock.calls[1];
const [approveUrl, approveInit] = vi.mocked(fetch).mock.calls[2];
const [rejectUrl, rejectInit] = vi.mocked(fetch).mock.calls[3];
expect(String(subSellerUrl)).toContain("/api/v1/admin/coin-sellers/3001/sub-sellers?");
expect(String(subSellerUrl)).toContain("page=1");
expect(subSellerInit?.method).toBe("GET");
expect(String(applicationUrl)).toContain("/api/v1/admin/coin-seller-sub-applications?");
expect(String(applicationUrl)).toContain("keyword=163001");
expect(String(applicationUrl)).toContain("parent_user_id=3001");
expect(String(applicationUrl)).toContain("target_user_id=3002");
expect(String(applicationUrl)).toContain("status=pending");
expect(applicationInit?.method).toBe("GET");
expect(String(approveUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-1/approve");
expect(approveInit?.method).toBe("POST");
expect(JSON.parse(String(approveInit?.body))).toMatchObject({ commandId: "approve-sub-app-test" });
expect(String(rejectUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-2/reject");
expect(rejectInit?.method).toBe("POST");
expect(JSON.parse(String(rejectInit?.body))).toMatchObject({ commandId: "reject-sub-app-test" });
});
test("country and region APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
@ -212,6 +276,13 @@ test("country and region APIs use generated admin paths", async () => {
enabled: true,
});
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
await renameCountryCode(12, {
countryCode: "USA",
countryDisplayName: "United States",
countryName: "United States",
sortOrder: 10,
});
await getJob(99);
await listRegions({ status: "active" });
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
await replaceRegionCountries(7, { countries: ["US", "CA"] });
@ -220,10 +291,12 @@ test("country and region APIs use generated admin paths", async () => {
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
const [countryRenameUrl, countryRenameInit] = vi.mocked(fetch).mock.calls[3];
const [jobUrl, jobInit] = vi.mocked(fetch).mock.calls[4];
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[5];
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[6];
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[7];
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[8];
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
expect(String(countryListUrl)).toContain("enabled=true");
@ -232,6 +305,12 @@ test("country and region APIs use generated admin paths", async () => {
expect(countryCreateInit?.method).toBe("POST");
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
expect(countryUpdateInit?.method).toBe("PATCH");
expect(JSON.parse(String(countryUpdateInit?.body))).not.toHaveProperty("countryCode");
expect(String(countryRenameUrl)).toContain("/api/v1/admin/countries/12/rename-code");
expect(countryRenameInit?.method).toBe("POST");
expect(JSON.parse(String(countryRenameInit?.body))).toMatchObject({ countryCode: "USA", sortOrder: 10 });
expect(String(jobUrl)).toContain("/api/v1/jobs/99");
expect(jobInit?.method).toBe("GET");
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
expect(String(regionListUrl)).toContain("status=active");
expect(regionListInit?.method).toBe("GET");

View File

@ -6,6 +6,7 @@ import type {
AgencyHostAddPayload,
AgencyJoinEnabledPayload,
AgencyStatusPayload,
AdminJobDto,
ApiList,
ApiPage,
BDLeaderPositionAliasPayload,
@ -14,13 +15,18 @@ import type {
CoinSellerDto,
CoinSellerSalaryRatesDto,
CoinSellerSalaryRatesPayload,
CoinSellerSubApplicationDto,
CoinSellerSubApplicationReviewPayload,
CoinSellerSubApplicationReviewResultDto,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStockDebitDto,
CoinSellerStockDebitPayload,
CoinSellerStatusPayload,
CountryCodeRenameJobDto,
CountryDto,
CountryPayload,
CountryRenameCodePayload,
CountryUpdatePayload,
CreateAgencyPayload,
CreateAgencyResultDto,
@ -78,6 +84,27 @@ export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload
);
}
export function renameCountryCode(
countryId: EntityId,
payload: CountryRenameCodePayload,
): Promise<CountryCodeRenameJobDto> {
const endpoint = API_ENDPOINTS.renameCountryCode;
return apiRequest<CountryCodeRenameJobDto, CountryRenameCodePayload>(
apiEndpointPath(API_OPERATIONS.renameCountryCode, { country_id: countryId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function getJob(jobId: EntityId): Promise<AdminJobDto> {
const endpoint = API_ENDPOINTS.getJob;
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
method: endpoint.method,
});
}
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.enableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
@ -192,10 +219,13 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
const endpoint = API_ENDPOINTS.updateManager;
return apiRequest<ManagerDto, UpdateManagerPayload>(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), {
return apiRequest<ManagerDto, UpdateManagerPayload>(
apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }),
{
body: payload,
method: endpoint.method,
});
},
);
}
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
@ -214,6 +244,28 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
});
}
export function listCoinSellerSubSellers(userId: EntityId, query: PageQuery = {}): Promise<ApiPage<CoinSellerDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerSubSellers;
return apiRequest<ApiPage<CoinSellerDto>>(
apiEndpointPath(API_OPERATIONS.listCoinSellerSubSellers, { user_id: userId }),
{
method: endpoint.method,
query,
},
);
}
export function listCoinSellerSubApplications(query: PageQuery = {}): Promise<ApiPage<CoinSellerSubApplicationDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerSubApplications;
return apiRequest<ApiPage<CoinSellerSubApplicationDto>>(
apiEndpointPath(API_OPERATIONS.listCoinSellerSubApplications),
{
method: endpoint.method,
query,
},
);
}
export function listHostWithdrawals(query: PageQuery = {}): Promise<ApiPage<HostWithdrawalDto>> {
const endpoint = API_ENDPOINTS.listHostWithdrawals;
return apiRequest<ApiPage<HostWithdrawalDto>>(apiEndpointPath(API_OPERATIONS.listHostWithdrawals), {
@ -312,6 +364,34 @@ export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusP
);
}
export function approveCoinSellerSubApplication(
applicationId: EntityId,
payload: CoinSellerSubApplicationReviewPayload,
): Promise<CoinSellerSubApplicationReviewResultDto> {
const endpoint = API_ENDPOINTS.approveCoinSellerSubApplication;
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
apiEndpointPath(API_OPERATIONS.approveCoinSellerSubApplication, { application_id: applicationId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function rejectCoinSellerSubApplication(
applicationId: EntityId,
payload: CoinSellerSubApplicationReviewPayload,
): Promise<CoinSellerSubApplicationReviewResultDto> {
const endpoint = API_ENDPOINTS.rejectCoinSellerSubApplication;
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
apiEndpointPath(API_OPERATIONS.rejectCoinSellerSubApplication, { application_id: applicationId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function creditCoinSellerStock(
userId: EntityId,
payload: CoinSellerStockCreditPayload,

View File

@ -13,10 +13,13 @@ export function HostOrgStatus({ value }) {
}
function getStatusTone(status) {
if (status === "active") {
if (status === "active" || status === "approved") {
return "running";
}
if (status === "closed" || status === "disabled") {
if (status === "pending") {
return "warning";
}
if (status === "closed" || status === "disabled" || status === "rejected") {
return "danger";
}
return "stopped";

View File

@ -1,53 +1,69 @@
export const agencyStatusFilters = [
["", "全部"],
["active", "启用"],
["closed", "已关闭"]
["closed", "已关闭"],
];
export const countryEnabledFilters = [
["", "全部"],
["enabled", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const regionStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const bdStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const managerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const hostStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const coinSellerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const coinSellerSubApplicationStatusFilters = [
["", "全部"],
["pending", "待审核"],
["approved", "已通过"],
["rejected", "已拒绝"],
];
export const coinSellerSubApplicationStatusLabels = {
approved: "已通过",
pending: "待审核",
rejected: "已拒绝",
};
export const hostSourceLabels = {
agency_application: "Agency 申请",
agency_invitation: "Agency 邀请",
admin_create_agency: "后台创建 Agency"
admin_create_agency: "后台创建 Agency",
};
export const statusLabels = {
active: "启用",
approved: "已通过",
closed: "已关闭",
disabled: "停用"
disabled: "停用",
pending: "待审核",
rejected: "已拒绝",
};

View File

@ -10,6 +10,7 @@ import {
debitCoinSellerStock,
getCoinSellerSalaryRates,
listCoinSellers,
listCoinSellerSubSellers,
replaceCoinSellerSalaryRates,
setCoinSellerStatus,
} from "@/features/host-org/api";
@ -23,8 +24,15 @@ import {
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", targetUserId: "" });
const emptyCreateForm = () => ({
canManageSubCoinSellers: true,
commandId: makeCommandId("coin-seller"),
contact: "",
reason: "",
targetUserId: "",
});
const emptyEditForm = () => ({
canManageSubCoinSellers: true,
commandId: makeCommandId("coin-seller-status"),
contact: "",
reason: "",
@ -72,6 +80,9 @@ export function useHostCoinSellersPage() {
const [loadingRates, setLoadingRates] = useState(false);
const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [loadingSubSellers, setLoadingSubSellers] = useState(false);
const [subSellerData, setSubSellerData] = useState(emptyData);
const [subSellerPage, setSubSellerPageState] = useState(1);
const [contactSavingIds, setContactSavingIds] = useState({});
const [sellerRowPatches, setSellerRowPatches] = useState({});
const filters = useMemo(
@ -150,6 +161,7 @@ export function useHostCoinSellersPage() {
}
setSelectedSeller(seller);
setEditForm({
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-status"),
contact: seller.contact || "",
reason: "update coin seller contact",
@ -185,6 +197,15 @@ export function useHostCoinSellersPage() {
setActiveAction("ledger");
};
const openSubSellerList = async (seller) => {
if (!seller?.userId || !abilities.canView) {
return;
}
setSelectedSeller(seller);
setActiveAction("sub-sellers");
await loadSubSellers(seller.userId, 1);
};
const openRateSettings = async () => {
if (!abilities.canExchangeRate) {
return;
@ -204,6 +225,31 @@ export function useHostCoinSellersPage() {
setSelectedSeller(null);
};
const loadSubSellers = async (sellerUserId = selectedSeller?.userId, nextPage = subSellerPage) => {
if (!sellerUserId) {
setSubSellerData(emptyData);
return;
}
setLoadingSubSellers(true);
try {
const nextData = await listCoinSellerSubSellers(sellerUserId, {
page: nextPage,
page_size: pageSize,
});
setSubSellerData(nextData || emptyData);
setSubSellerPageState(nextPage);
} catch (err) {
setSubSellerData(emptyData);
showToast(err.message || "加载子币商列表失败", "error");
} finally {
setLoadingSubSellers(false);
}
};
const setSubSellerPage = async (nextPage) => {
await loadSubSellers(selectedSeller?.userId, nextPage);
};
const changeRateRegionId = async (value) => {
setRateRegionId(value);
if (value) {
@ -265,6 +311,7 @@ export function useHostCoinSellersPage() {
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
"币商已更新",
async () => {
// 编辑接口复用 status 命令,提交时带上权限字段,避免只改联系方式时把子币商权限丢给后端默认值处理。
const payload = parseForm(coinSellerStatusSchema, editForm);
const { targetUserId, ...body } = payload;
await setCoinSellerStatus(targetUserId, body);
@ -286,7 +333,9 @@ export function useHostCoinSellersPage() {
const sellerID = seller.userId;
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
try {
// 联系方式行内保存同样走状态接口,显式保留当前权限位,避免局部保存覆盖行级权限。
await setCoinSellerStatus(sellerID, {
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-contact"),
contact,
reason: "update coin seller contact",
@ -313,6 +362,37 @@ export function useHostCoinSellersPage() {
}
};
const toggleSubCoinSellerPermission = async (seller, nextEnabled = !seller?.canManageSubCoinSellers) => {
if (!abilities.canUpdate || !seller?.userId) {
return;
}
if ((seller.canManageSubCoinSellers === true) === nextEnabled) {
return;
}
await runAction(
`coin-seller-sub-permission-${seller.userId}`,
nextEnabled ? "子币商权限已开启" : "子币商权限已关闭",
async () => {
// 子权限开关复用币商状态接口,必须保留 status/contact防止权限更新被解释成状态重置。
await setCoinSellerStatus(seller.userId, {
canManageSubCoinSellers: nextEnabled,
commandId: makeCommandId("coin-seller-sub-permission"),
contact: seller.contact || "",
reason: "update coin seller sub permission",
status: seller.status || "active",
});
setSellerRowPatches((current) => ({
...current,
[seller.userId]: {
...(current[seller.userId] || {}),
canManageSubCoinSellers: nextEnabled,
},
}));
await reload();
},
);
};
const submitStockCredit = async (event) => {
event.preventDefault();
if (!selectedSeller?.userId) {
@ -373,7 +453,9 @@ export function useHostCoinSellersPage() {
return;
}
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
// 状态开关只改变启停状态,显式保留权限位,避免后端按缺省值改写子币商授权。
await setCoinSellerStatus(seller.userId, {
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-status"),
reason: "",
status: nextStatus,
@ -409,6 +491,7 @@ export function useHostCoinSellersPage() {
error,
loading,
loadingAction,
loadingSubSellers,
loadingRates,
loadingRegions,
openRateSettings,
@ -417,6 +500,7 @@ export function useHostCoinSellersPage() {
openSellerLedger,
openStockCredit,
openStockDebit,
openSubSellerList,
page,
regionId,
regionOptions,
@ -434,9 +518,12 @@ export function useHostCoinSellersPage() {
setPage,
setStockForm,
setStockDebitForm,
setSubSellerPage,
status,
stockDebitForm,
stockForm,
subSellerData,
subSellerPage,
sortBy,
sortDirection,
submitRateSettings,
@ -444,6 +531,7 @@ export function useHostCoinSellersPage() {
submitEditSeller,
submitStockCredit,
submitStockDebit,
toggleSubCoinSellerPermission,
toggleSeller,
updateRateTier,
userFilter,

View File

@ -2,9 +2,17 @@ import { useCallback, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCountry, disableCountry, enableCountry, listCountries, updateCountry } from "@/features/host-org/api";
import {
createCountry,
disableCountry,
enableCountry,
getJob,
listCountries,
renameCountryCode,
updateCountry,
} from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
import { countryCreateSchema, countryRenameCodeSchema, countryUpdateSchema } from "@/features/host-org/schema";
const emptyCountryForm = () => ({
countryCode: "",
@ -86,15 +94,56 @@ export function useHostCountriesPage() {
const submitCountry = async (event) => {
event.preventDefault();
const isEdit = activeAction === "edit";
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
if (!isEdit) {
await runAction("country-create", "国家已创建", async () => {
const payload = parseForm(countryCreateSchema, countryForm);
const data = await createCountry(payload);
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
await reload();
return data;
});
return;
}
const currentCountryCode = normalizeCountryCode(editingCountry?.countryCode);
const nextCountryCode = normalizeCountryCode(countryForm.countryCode);
if (currentCountryCode !== nextCountryCode) {
await runCountryCodeRename();
return;
}
await runAction("country-edit", "国家已更新", async () => {
const { countryCode: _countryCode, enabled: _enabled, ...formWithoutImmutableFields } = countryForm;
const payload = parseForm(countryUpdateSchema, formWithoutImmutableFields);
const data = await updateCountry(editingCountry.countryId, payload);
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
await reload();
return data;
});
};
const runCountryCodeRename = async () => {
setLoadingAction("country-rename");
try {
const payload = parseForm(countryRenameCodeSchema, countryForm);
const job = await renameCountryCode(editingCountry.countryId, payload);
showToast(`国家码重命名任务已创建:#${job.jobId}`, "success");
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
// 国家码会同步 user/admin/room 多个当前态,只有 job succeeded 后才刷新列表,避免短暂显示新旧码混杂。
await waitCountryCodeRenameJob(job.jobId);
await reload();
showToast("国家码重命名完成", "success");
} catch (err) {
showToast(err.message || "国家码重命名失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
@ -144,6 +193,33 @@ export function useHostCountriesPage() {
};
}
async function waitCountryCodeRenameJob(jobId) {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const job = await getJob(jobId);
if (job.status === "succeeded") {
return job;
}
if (job.status === "failed" || job.status === "canceled") {
throw new Error(job.error || "国家码重命名任务失败");
}
await delay(1500);
}
throw new Error("国家码重命名任务仍在处理中,请稍后刷新查看");
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function normalizeCountryCode(value) {
return String(value || "")
.trim()
.toUpperCase();
}
function filterCountries(items, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) {

View File

@ -0,0 +1,127 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { useHostCountriesPage } from "./useHostCountriesPage.js";
import { getJob, renameCountryCode, updateCountry } from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const mocks = vi.hoisted(() => ({
reload: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@/features/host-org/api", () => ({
createCountry: vi.fn(),
disableCountry: vi.fn(),
enableCountry: vi.fn(),
getJob: vi.fn(),
listCountries: vi.fn(),
renameCountryCode: vi.fn(),
updateCountry: vi.fn(),
}));
vi.mock("@/features/host-org/permissions.js", () => ({
useCountryAbilities: vi.fn(),
}));
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
useAdminQuery: vi.fn(),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("country edit without code change uses metadata update endpoint", async () => {
mockHookDeps();
vi.mocked(updateCountry).mockResolvedValue({ countryId: 12, countryCode: "PH" });
const { result } = renderHook(() => useHostCountriesPage());
act(() => {
result.current.openEditCountry(countryFixture());
});
await act(async () => {
await result.current.submitCountry({ preventDefault: vi.fn() });
});
expect(updateCountry).toHaveBeenCalledWith(
12,
expect.not.objectContaining({
countryCode: expect.anything(),
}),
);
expect(renameCountryCode).not.toHaveBeenCalled();
expect(mocks.reload).toHaveBeenCalled();
});
test("country edit with code change creates rename job and polls until success", async () => {
mockHookDeps();
vi.mocked(renameCountryCode).mockResolvedValue({
countryId: 12,
jobId: 99,
newCountryCode: "PHL",
oldCountryCode: "PH",
status: "pending",
});
vi.mocked(getJob).mockResolvedValue({ id: 99, status: "succeeded", type: "country-code-rename" });
const { result } = renderHook(() => useHostCountriesPage());
act(() => {
result.current.openEditCountry(countryFixture());
});
act(() => {
result.current.setCountryForm({ ...result.current.countryForm, countryCode: "phl" });
});
await act(async () => {
await result.current.submitCountry({ preventDefault: vi.fn() });
});
expect(renameCountryCode).toHaveBeenCalledWith(
12,
expect.objectContaining({
countryCode: "PHL",
countryDisplayName: "菲律宾",
countryName: "Philippines",
}),
);
expect(getJob).toHaveBeenCalledWith(99);
expect(updateCountry).not.toHaveBeenCalled();
expect(mocks.showToast).toHaveBeenCalledWith("国家码重命名完成", "success");
expect(mocks.reload).toHaveBeenCalled();
});
function mockHookDeps() {
vi.mocked(useCountryAbilities).mockReturnValue({
canCreate: true,
canStatus: true,
canUpdate: true,
canView: true,
});
vi.mocked(useToast).mockReturnValue({ showToast: mocks.showToast });
vi.mocked(useAdminQuery).mockReturnValue({
data: { items: [], total: 0 },
error: null,
loading: false,
reload: mocks.reload,
});
}
function countryFixture() {
return {
countryCode: "PH",
countryDisplayName: "菲律宾",
countryId: 12,
countryName: "Philippines",
enabled: true,
flag: "🇵🇭",
isoAlpha3: "PHL",
isoNumeric: "608",
phoneCountryCode: "+63",
sortOrder: 1770,
};
}

View File

@ -17,29 +17,48 @@ export const managerPermissionFields = [
"canGrantVip",
"canUpdateUserLevel",
"canAddBdLeader",
"canManageCrossRegion",
"canAddAdmin",
"canAddSuperadmin",
"canBlockUser",
"canTransferUserCountry",
];
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
const managerPermissionDefault = (field) => field !== "canManageCrossRegion";
const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermissions() });
const managerPermissionEnabled = (source, field) => {
const value =
field === "canManageCrossRegion"
? (source?.canManageCrossRegion ?? source?.canAddBdLeaderInRegion)
: source?.[field];
return value === undefined || value === null ? managerPermissionDefault(field) : value !== false;
};
const emptyPermissions = () =>
Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionDefault(field)]));
const emptyManagerForm = (crossRegionConfigurable = false) => ({
managerCrossRegionConfigurable: crossRegionConfigurable,
contact: "",
targetUserId: "",
...emptyPermissions(),
});
const managerPermissionPayload = (form) =>
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(form, field)]));
const managerStatusPayload = (item, status) => ({
contact: item?.contact || "",
status,
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
});
const managerFormFromItem = (item) => ({
managerCrossRegionConfigurable:
item?.managerCrossRegionConfigurable === true || item?.bdLeaderRegionExpansionConfigurable === true,
contact: item?.contact || "",
targetUserId: String(item?.userId || ""),
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
...Object.fromEntries(managerPermissionFields.map((field) => [field, managerPermissionEnabled(item, field)])),
});
export function useHostManagersPage() {
@ -118,7 +137,7 @@ export function useHostManagersPage() {
});
showToast("经理已添加", "success");
}
setManagerForm(emptyManagerForm());
setManagerForm(emptyManagerForm(managerCrossRegionConfigurable(data)));
setActiveAction("");
setEditingManager(null);
await reload();
@ -132,12 +151,12 @@ export function useHostManagersPage() {
const closeAction = () => {
setActiveAction("");
setEditingManager(null);
setManagerForm(emptyManagerForm());
setManagerForm(emptyManagerForm(managerCrossRegionConfigurable(data)));
};
const openManagerForm = () => {
setEditingManager(null);
setManagerForm(emptyManagerForm());
setManagerForm(emptyManagerForm(managerCrossRegionConfigurable(data)));
setActiveAction("manager");
};
@ -191,3 +210,10 @@ export function useHostManagersPage() {
userFilter,
};
}
function managerCrossRegionConfigurable(data) {
return (
data?.managerOperationScopePolicy?.regionExpansionConfigurable === true ||
data?.bdLeaderInviteScopePolicy?.regionExpansionConfigurable === true
);
}

View File

@ -0,0 +1,152 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
approveCoinSellerSubApplication,
listCoinSellerSubApplications,
rejectCoinSellerSubApplication,
} from "@/features/host-org/api";
import { useCoinSellerSubApplicationAbilities } from "@/features/host-org/permissions.js";
import { coinSellerSubApplicationReviewSchema } from "@/features/host-org/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyReviewForm = (decision = "approved") => ({
commandId: makeCommandId(`coin-seller-sub-${decision}`),
reason: "",
});
export function useHostSubCoinSellerApplicationsPage() {
const abilities = useCoinSellerSubApplicationAbilities();
const { showToast } = useToast();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("pending");
const [parentUserId, setParentUserId] = useState("");
const [targetUserId, setTargetUserId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedApplication, setSelectedApplication] = useState(null);
const [reviewDecision, setReviewDecision] = useState("approved");
const [reviewForm, setReviewForm] = useState(emptyReviewForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword,
parent_user_id: parentUserId,
status,
target_user_id: targetUserId,
}),
[keyword, parentUserId, status, targetUserId],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载子币商申请列表失败",
fetcher: listCoinSellerSubApplications,
filters,
page,
pageSize,
queryKey: ["host-org", "coin-seller-sub-applications", filters, page],
});
const changeKeyword = (value) => {
setKeyword(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeParentUserId = (value) => {
setParentUserId(String(value || "").trim());
setPage(1);
};
const changeTargetUserId = (value) => {
setTargetUserId(String(value || "").trim());
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setParentUserId("");
setTargetUserId("");
setPage(1);
};
const openReview = (application, decision) => {
if (!application?.applicationId || !abilities.canAudit || application.status !== "pending") {
return;
}
// 审核 commandId 与列表刷新分离,避免弹窗重复提交时把另一次审核误认为同一请求。
setSelectedApplication(application);
setReviewDecision(decision);
setReviewForm(emptyReviewForm(decision));
setActiveAction("review");
};
const closeAction = () => {
setActiveAction("");
setSelectedApplication(null);
};
const submitReview = async (event) => {
event.preventDefault();
if (!selectedApplication?.applicationId || !abilities.canAudit) {
return;
}
const payload = parseForm(coinSellerSubApplicationReviewSchema, reviewForm);
const submitter =
reviewDecision === "approved" ? approveCoinSellerSubApplication : rejectCoinSellerSubApplication;
setLoadingAction(`review-${selectedApplication.applicationId}`);
try {
await submitter(selectedApplication.applicationId, payload);
showToast(reviewDecision === "approved" ? "子币商申请已通过" : "子币商申请已拒绝", "success");
closeAction();
await reload();
} catch (err) {
showToast(err.message || "审核子币商申请失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeKeyword,
changeParentUserId,
changeStatus,
changeTargetUserId,
closeAction,
data,
error,
keyword,
loading,
loadingAction,
openReview,
page,
parentUserId,
resetFilters,
reviewDecision,
reviewForm,
selectedApplication,
setPage,
setReviewForm,
status,
submitReview,
targetUserId,
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -2,6 +2,7 @@ import Add from "@mui/icons-material/Add";
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
@ -16,6 +17,7 @@ import {
AdminFormFieldGrid,
AdminFormReadOnlyField,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
@ -76,6 +78,39 @@ const baseColumns = [
},
];
const subSellerColumns = [
{
key: "seller",
label: "子币商",
render: (item) => <SellerIdentity item={item} />,
width: "minmax(220px, 1.3fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(140px, 0.8fr)",
},
{
key: "status",
label: "状态",
render: (item) => item.status || "-",
width: "minmax(90px, 0.5fr)",
},
{
key: "merchantBalance",
label: "币商余额",
render: (item) => formatNumber(item.merchantBalance),
width: "minmax(120px, 0.8fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
];
export function HostCoinSellersPage() {
const page = useHostCoinSellersPage();
const items = page.data.items || [];
@ -91,13 +126,25 @@ export function HostCoinSellersPage() {
render: (item) => <SellerStatusSwitch item={item} page={page} />,
width: "minmax(90px, 0.6fr)",
},
{
key: "canManageSubCoinSellers",
label: "子币商权限",
render: (item) => <SubCoinSellerPermissionSwitch item={item} page={page} />,
width: "minmax(120px, 0.7fr)",
},
{
key: "subCoinSellerCount",
label: "子币商数量",
render: (item) => <SubCoinSellerCountButton item={item} page={page} />,
width: "minmax(120px, 0.7fr)",
},
...baseColumns.slice(2),
page.abilities.canStockCredit || page.abilities.canLedger
page.abilities.canUpdate || page.abilities.canStockCredit || page.abilities.canLedger
? {
key: "actions",
label: "操作",
render: (item) => <SellerActions item={item} page={page} />,
width: "minmax(130px, 0.65fr)",
width: "minmax(160px, 0.75fr)",
}
: null,
]
@ -167,7 +214,7 @@ export function HostCoinSellersPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="930px"
minWidth="1080px"
pagination={
total > 0
? {
@ -208,6 +255,12 @@ export function HostCoinSellersPage() {
value={page.createForm.contact}
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
/>
<AdminFormSwitchField
checked={page.createForm.canManageSubCoinSellers !== false}
disabled={createDisabled}
label="子币商权限"
onChange={(checked) => page.setCreateForm({ ...page.createForm, canManageSubCoinSellers: checked })}
/>
</HostOrgActionModal>
<HostOrgActionModal
@ -226,6 +279,12 @@ export function HostCoinSellersPage() {
value={page.editForm.contact}
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
/>
<AdminFormSwitchField
checked={page.editForm.canManageSubCoinSellers === true}
disabled={updateDisabled}
label="子币商权限"
onChange={(checked) => page.setEditForm({ ...page.editForm, canManageSubCoinSellers: checked })}
/>
</HostOrgActionModal>
<HostOrgActionModal
@ -358,7 +417,9 @@ export function HostCoinSellersPage() {
required
minRows={2}
value={stockDebitForm.reason}
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
onChange={(event) =>
page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })
}
/>
</AdminFormSection>
</HostOrgActionModal>
@ -379,6 +440,35 @@ export function HostCoinSellersPage() {
) : null}
</SideDrawer>
<SideDrawer
className={styles.ledgerDrawer}
contentClassName={styles.ledgerDrawerBody}
open={page.activeAction === "sub-sellers"}
title={`子币商列表 - ${sellerDrawerTitle(page.selectedSeller)}`}
width="wide"
onClose={page.closeAction}
>
<DataState loading={page.loadingSubSellers}>
<HostOrgTable
columns={subSellerColumns}
context={{ regionOptions: page.regionOptions }}
items={page.subSellerData.items || []}
minWidth="860px"
pagination={
Number(page.subSellerData.total || 0) > 0
? {
page: page.subSellerPage,
pageSize: page.subSellerData.pageSize || 50,
total: page.subSellerData.total,
onPageChange: page.setSubSellerPage,
}
: undefined
}
rowKey={(item) => item.userId}
/>
</DataState>
</SideDrawer>
<SideDrawer
actions={
<>
@ -482,6 +572,33 @@ function SellerStatusSwitch({ item, page }) {
);
}
function SubCoinSellerPermissionSwitch({ item, page }) {
return (
<AdminSwitch
checked={item.canManageSubCoinSellers === true}
checkedLabel="开"
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-sub-permission-${item.userId}`}
inputProps={{ "aria-label": item.canManageSubCoinSellers === true ? "关闭子币商权限" : "开启子币商权限" }}
uncheckedLabel="关"
onChange={(event) => page.toggleSubCoinSellerPermission(item, event.target.checked)}
/>
);
}
function SubCoinSellerCountButton({ item, page }) {
const count = Number(item.subCoinSellerCount || 0);
return (
<Button
disabled={!page.abilities.canView || page.loadingSubSellers}
size="small"
variant="text"
onClick={() => page.openSubSellerList(item)}
>
{formatNumber(count)}
</Button>
);
}
function SellerContact({ item, page }) {
const contact = String(item.contact || "").trim();
return (
@ -505,6 +622,15 @@ function SellerContact({ item, page }) {
function SellerActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canUpdate ? (
<AdminActionIconButton
disabled={page.loadingAction === `coin-seller-edit-${item.userId}`}
label="编辑币商"
onClick={() => page.openEditSeller(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canLedger ? (
<AdminActionIconButton
label="币商流水"

View File

@ -6,6 +6,8 @@ import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSel
const mocks = vi.hoisted(() => ({
ledgerTable: vi.fn(),
openSellerLedger: vi.fn(),
openSubSellerList: vi.fn(),
toggleSubCoinSellerPermission: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
@ -41,9 +43,45 @@ test("coin seller table shows cumulative USDT and hides creator id column", () =
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
expect(screen.getByText("累充USDT")).toBeInTheDocument();
expect(screen.getByText("子币商权限")).toBeInTheDocument();
expect(screen.getByText("子币商数量")).toBeInTheDocument();
expect(screen.getByText("12.5")).toBeInTheDocument();
});
test("coin seller sub seller count opens direct child seller drawer", () => {
const seller = sellerFixture({ subCoinSellerCount: 2 });
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
fireEvent.click(screen.getByRole("button", { name: "2" }));
expect(mocks.openSubSellerList).toHaveBeenCalledWith(seller);
});
test("coin seller sub permission switch toggles through the page hook", () => {
const seller = sellerFixture();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: true },
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "关闭子币商权限" });
expect(switchInput).toBeChecked();
fireEvent.click(switchInput);
expect(mocks.toggleSubCoinSellerPermission).toHaveBeenCalledWith(seller, false);
});
test("coin seller balance and cumulative USDT headers request sorting", () => {
const changeSort = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
@ -83,7 +121,84 @@ test("coin seller ledger drawer passes locked seller id to shared table", () =>
render(<HostCoinSellersPage />);
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
expect(mocks.ledgerTable).toHaveBeenCalledWith(
expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }),
);
});
test("coin seller sub seller drawer renders approved direct children", () => {
const seller = sellerFixture({ subCoinSellerCount: 1 });
const child = sellerFixture({
displayUserId: "168933",
merchantBalance: 1200,
subCoinSellerCount: 0,
userId: "3002",
username: "Child seller",
});
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
activeAction: "sub-sellers",
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
selectedSeller: seller,
subSellerData: { items: [child], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
expect(screen.getByText("子币商列表 - Flower92 / 164425")).toBeInTheDocument();
expect(screen.getByText("Child seller")).toBeInTheDocument();
expect(screen.getByText("168933")).toBeInTheDocument();
});
test("coin seller create form includes sub permission switch", () => {
const setCreateForm = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canCreate: true },
activeAction: "create",
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
setCreateForm,
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
expect(switchInput).toBeChecked();
fireEvent.click(switchInput);
expect(setCreateForm).toHaveBeenCalledWith(
expect.objectContaining({
canManageSubCoinSellers: false,
}),
);
});
test("coin seller edit form preserves sub permission field", () => {
const setEditForm = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: true },
activeAction: "edit",
editForm: { canManageSubCoinSellers: false, contact: "+63", targetUserId: "3001" },
setEditForm,
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
expect(switchInput).not.toBeChecked();
fireEvent.click(switchInput);
expect(setEditForm).toHaveBeenCalledWith(
expect.objectContaining({
canManageSubCoinSellers: true,
}),
);
});
function pageFixture(patch = {}) {
@ -105,9 +220,9 @@ function pageFixture(patch = {}) {
changeStatus: vi.fn(),
closeAction: vi.fn(),
contactSavingIds: {},
createForm: { contact: "", targetUserId: "" },
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { contact: "", targetUserId: "" },
editForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
error: null,
loading: false,
loadingAction: "",
@ -118,6 +233,7 @@ function pageFixture(patch = {}) {
openRateSettings: vi.fn(),
openSellerLedger: mocks.openSellerLedger,
openStockCredit: vi.fn(),
openSubSellerList: mocks.openSubSellerList,
page: 1,
query: "",
rateRegionId: "",
@ -132,8 +248,11 @@ function pageFixture(patch = {}) {
setCreateForm: vi.fn(),
setEditForm: vi.fn(),
setPage: vi.fn(),
setSubSellerPage: vi.fn(),
setStockForm: vi.fn(),
status: "",
subSellerData: { items: [], page: 1, pageSize: 50, total: 0 },
subSellerPage: 1,
stockDebitForm: { coinAmount: "", reason: "", rechargeAmount: "" },
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
sortBy: "",
@ -142,24 +261,28 @@ function pageFixture(patch = {}) {
submitEditSeller: vi.fn(),
submitRateSettings: vi.fn(),
submitStockCredit: vi.fn(),
toggleSubCoinSellerPermission: mocks.toggleSubCoinSellerPermission,
toggleSeller: vi.fn(),
updateRateTier: vi.fn(),
...patch,
};
}
function sellerFixture() {
function sellerFixture(patch = {}) {
return {
avatar: "",
canManageSubCoinSellers: true,
contact: "菲律賓 +63",
displayUserId: "164425",
merchantAssetType: "COIN_SELLER_COIN",
merchantBalance: 9400000,
regionId: 1,
status: "active",
subCoinSellerCount: 2,
totalRechargeUsdtMicro: 12500000,
updatedAtMs: 1760000000000,
userId: "3001",
username: "Flower92",
...patch,
};
}

Some files were not shown because too many files have changed in this diff Show More