Compare commits

...

7 Commits

Author SHA1 Message Date
local
3e8d4b4aea bi报表和财务系统 2026-06-28 23:25:01 +08:00
local
f059b0c60c 财务系统和新BI 2026-06-28 15:11:48 +08:00
local
7858530fc8 靓号查询 2026-06-27 22:46:48 +08:00
zhx
63d4ffbacd 优化靓号页面展示 2026-06-27 01:09:55 +08:00
zhx
8c9f5d35b2 成就,等级配置 2026-06-26 16:10:59 +08:00
zhx
0112303b88 资源发放 2026-06-26 12:58:27 +08:00
zhx
c33fb469d9 三方 2026-06-25 22:09:45 +08:00
84 changed files with 11268 additions and 3298 deletions

View File

@ -46,7 +46,27 @@
"name": "achievement_id",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
"x-permission": "achievement:update",
"x-permissions": ["achievement:update"]
},
"delete": {
"operationId": "deleteAchievementDefinition",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "achievement_id",
"required": true,
"schema": {
"type": "string"
}
}
],
@ -2514,6 +2534,44 @@
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"name": "user_keyword",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "biz_type",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "start_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "end_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"x-permission": "coin-ledger:view",
"x-permissions": ["coin-ledger:view"]
}
@ -2671,6 +2729,30 @@
"x-permissions": ["payment-product:delete"]
}
},
"/admin/money/scope": {
"get": {
"operationId": "getMoneyScope",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "money:view",
"x-permissions": ["money:view"]
}
},
"/admin/money/performance": {
"get": {
"operationId": "getMoneyPerformance",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "money:view",
"x-permissions": ["money:view"]
}
},
"/admin/payment/temporary-links": {
"get": {
"operationId": "listTemporaryPaymentLinks",
@ -2680,7 +2762,20 @@
}
},
"x-permission": "payment-temporary-link:view",
"x-permissions": ["payment-temporary-link:view"]
"x-permissions": ["payment-temporary-link:view", "money:view"]
},
"post": {
"operationId": "createTemporaryPaymentLink",
"requestBody": {
"$ref": "#/components/requestBodies/TemporaryPaymentLinkRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "money:payment-link:create",
"x-permissions": ["money:payment-link:create"]
}
},
"/admin/payment/temporary-links/{order_id}": {
@ -2702,7 +2797,7 @@
}
],
"x-permission": "payment-temporary-link:view",
"x-permissions": ["payment-temporary-link:view"]
"x-permissions": ["payment-temporary-link:view", "money:view"]
}
},
"/admin/payment/third-party-channels": {
@ -2714,7 +2809,7 @@
}
},
"x-permission": "payment-third-party:view",
"x-permissions": ["payment-third-party:view"]
"x-permissions": ["payment-third-party:view", "money:view"]
}
},
"/admin/payment/third-party-methods/{method_id}/status": {
@ -2739,6 +2834,33 @@
"x-permissions": ["payment-third-party:update"]
}
},
"/admin/payment/third-party-methods/sync": {
"post": {
"operationId": "syncThirdPartyPaymentMethods",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"providerCode": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-third-party:update",
"x-permissions": ["payment-third-party:update"]
}
},
"/admin/payment/third-party-rates/{method_id}": {
"patch": {
"operationId": "updateThirdPartyPaymentRate",
@ -3330,6 +3452,44 @@
"/admin/rooms": {
"get": {
"operationId": "listRooms",
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"$ref": "#/components/parameters/Keyword"
},
{
"name": "owner_user_keyword",
"in": "query",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Status"
},
{
"$ref": "#/components/parameters/RegionId"
},
{
"name": "sort_by",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sort_direction",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
@ -4395,6 +4555,41 @@
"x-permissions": ["user:update"]
}
},
"/users/{id}/money-scopes": {
"get": {
"operationId": "listUserMoneyScopes",
"x-permission": "user:view",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permissions": ["user:view"]
},
"put": {
"operationId": "replaceUserMoneyScopes",
"x-permission": "user:update",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"requestBody": {
"$ref": "#/components/requestBodies/MoneyScopeRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permissions": ["user:update"]
}
},
"/users/{id}/reset-password": {
"post": {
"operationId": "resetUserPassword",
@ -4699,6 +4894,24 @@
}
}
},
"MoneyScopeRequest": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["scopes"],
"properties": {
"scopes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserMoneyScope"
}
}
}
}
}
}
},
"MenuRequest": {
"content": {
"application/json": {
@ -4801,6 +5014,39 @@
}
}
},
"TemporaryPaymentLinkRequest": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["appCode", "regionId", "usdMinorAmount", "providerCode", "paymentMethodId"],
"properties": {
"appCode": {
"type": "string"
},
"language": {
"type": "string"
},
"paymentMethodId": {
"type": "integer"
},
"providerCode": {
"type": "string"
},
"regionId": {
"type": "integer"
},
"returnUrl": {
"type": "string"
},
"usdMinorAmount": {
"type": "integer"
}
}
}
}
}
},
"UserRequest": {
"content": {
"application/json": {
@ -5848,6 +6094,18 @@
"type": "object",
"required": ["name"],
"additionalProperties": true
},
"UserMoneyScope": {
"type": "object",
"required": ["appCode", "regionId"],
"properties": {
"appCode": {
"type": "string"
},
"regionId": {
"type": "integer"
}
}
}
}
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HYApp 数据大屏</title>
<title>HYApp 数据中心</title>
</head>
<body>
<div id="databi-root"></div>

View File

@ -7,6 +7,7 @@ import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
import { ReportOverview } from "./components/ReportOverview.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
@ -26,6 +27,7 @@ export function DatabiApp() {
const [countryId, setCountryId] = useState(0);
const [regionId, setRegionId] = useState("all");
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
const [viewMode, setViewMode] = useState("report");
const [activeScreen, setActiveScreen] = useState("overview");
const [gameId, setGameId] = useState("all");
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
@ -192,13 +194,18 @@ export function DatabiApp() {
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
const showSkeleton = loading && !overview;
const isReportView = viewMode === "report";
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
}, []);
return (
<main className="databi-shell">
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
<main className={`databi-shell databi-shell--${viewMode}`}>
<section className={[
"databi-screen",
isReportView ? "databi-screen--report" : "databi-screen--bigscreen",
activeScreen === "selfGames" ? "databi-screen--self-game" : ""
].filter(Boolean).join(" ")}>
<DatabiHeader
activeScreen={activeScreen}
appCode={appCode}
@ -221,55 +228,75 @@ export function DatabiApp() {
timeZone={timeZone}
timeZoneOptions={TIME_ZONE_OPTIONS}
updatedAt={model.updatedAt}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{activeScreen === "selfGames" ? (
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
) : isReportView ? (
<ReportOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
) : (
<>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard
key={item.label}
item={item}
loading={showSkeleton}
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
/>
))}
</section>
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
{model.robotGiftKpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
</section>
</>
<BigscreenOverview
loading={showSkeleton}
model={model}
onMetricTrend={setTrendModalItem}
onOpenLuckyGiftPools={openLuckyGiftPools}
/>
)}
</section>
{activeScreen === "overview" ? (
{activeScreen === "overview" && !isReportView ? (
<>
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
</>
) : null}
{activeScreen === "overview" && poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
{activeScreen === "overview" && trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
</main>
);
}
function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools }) {
return (
<>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
))}
</section>
<section className="business-metric-grid" aria-label="业务指标">
{model.businessKpis.map((item) => (
<MetricCard
key={item.label}
item={item}
loading={loading}
onClick={item.detailKey === "luckyGiftPools" ? onOpenLuckyGiftPools : undefined}
/>
))}
</section>
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
{model.robotGiftKpis.map((item) => (
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
))}
</section>
<section className="databi-grid databi-grid--top">
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={loading} topCountries={model.topCountries} />
<RevenueTrendPanel loading={loading} revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} loading={loading} sideMetrics={model.sideMetrics} />
</section>
</>
);
}
function resolveSelectedAppCode(options, current) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
@ -317,23 +344,26 @@ function countryBelongsToRegion(country, regionId) {
}
function enrichCountryBreakdown(data, countries) {
if (!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) {
if ((!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) &&
(!Array.isArray(data?.daily_country_breakdown) || !data.daily_country_breakdown.length)) {
return data;
}
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
const enrichRows = (rows) => rows.map((row) => {
const country = countryById.get(Number(row.country_id ?? row.countryId));
if (!country) {
return row;
}
return {
...row,
country: row.country || country.name || country.label,
country_code: row.country_code || country.countryCode,
flag: row.flag || country.flag
};
});
return {
...data,
country_breakdown: data.country_breakdown.map((row) => {
const country = countryById.get(Number(row.country_id ?? row.countryId));
if (!country) {
return row;
}
return {
...row,
country: row.country || country.name || country.label,
country_code: row.country_code || country.countryCode,
flag: row.flag || country.flag
};
})
country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown,
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
};
}

View File

@ -42,7 +42,8 @@ test("keeps current data visible during scheduled refresh", async () => {
await flushEffects();
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
expect(screen.getByLabelText("数据报表")).toBeTruthy();
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(60_000);
@ -50,7 +51,7 @@ test("keeps current data visible during scheduled refresh", async () => {
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
});
test("queries China natural day statistics when display timezone is Beijing", async () => {
@ -69,6 +70,35 @@ test("queries China natural day statistics when display timezone is Beijing", as
}));
});
test("renders coin seller stock and outbound coin columns in report view", async () => {
fetchStatisticsOverview.mockResolvedValue({
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
salary_transfer_coin: 88_000,
country_breakdown: [
{
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
country: "巴西",
country_id: 86,
salary_transfer_coin: 88_000
}
],
updated_at_ms: 1
});
render(<DatabiApp />);
await flushEffects();
expect(screen.getByRole("columnheader", { name: "币商充值金币" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "币商出货金币" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "工资兑换金币" })).toBeTruthy();
expect(screen.getAllByText("400,000").length).toBeGreaterThan(0);
expect(screen.getAllByText("160,000").length).toBeGreaterThan(0);
expect(screen.getAllByText("88,000").length).toBeGreaterThan(0);
});
test("loads self game statistics from the big screen switch", async () => {
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
fetchSelfGameStatisticsOverview.mockResolvedValue({
@ -103,6 +133,9 @@ test("renders real-room robot gift cards in a separate row", async () => {
render(<DatabiApp />);
await flushEffects();
await act(async () => {
screen.getByRole("tab", { name: "大屏 BI" }).click();
});
const businessSection = screen.getByLabelText("业务指标");
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");

View File

@ -0,0 +1,72 @@
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
import { niceAxis } from "./createMetricTrendOption.js";
export function createReportRevenueOption(series) {
const labels = series.map((item) => item.label);
const channelValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller));
const totalValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total));
const channelAxis = niceAxis(channelValues);
const totalAxis = niceAxis(totalValues);
return {
animationDuration: 520,
color: ["#1688d9", "#18a389", "#6377e8", "#172033"],
grid: { bottom: 30, left: 54, right: 54, top: 42 },
legend: [
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 18, textStyle: { color: "#5f6f86", fontSize: 12 }, top: 0 },
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#172033", fontSize: 12 }, top: 0 }
],
tooltip: {
backgroundColor: "#ffffff",
borderColor: "#d8e2ed",
extraCssText: "box-shadow: 0 14px 34px rgba(15, 23, 42, .14);",
textStyle: { color: "#172033" },
trigger: "axis"
},
xAxis: {
axisLabel: { color: "#6b7a90", margin: 10 },
axisLine: { lineStyle: { color: "#dbe5ef" } },
axisTick: { show: false },
boundaryGap: true,
data: labels,
type: "category"
},
yAxis: [
{
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
axisTick: { show: false },
interval: channelAxis.interval,
max: channelAxis.max,
min: 0,
splitLine: { lineStyle: { color: "rgba(104, 123, 148, 0.16)", type: "dashed" } },
type: "value"
},
{
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
axisTick: { show: false },
interval: totalAxis.interval,
max: totalAxis.max,
min: 0,
splitLine: { show: false },
type: "value"
}
],
series: [
{ barGap: "-100%", barWidth: 26, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
{ barWidth: 26, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
{ barWidth: 26, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
{
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
lineStyle: { color: "#172033", shadowBlur: 8, shadowColor: "rgba(23, 32, 51, .14)", width: 3 },
name: "总计 (USD)",
smooth: 0.42,
symbol: "circle",
symbolSize: 7,
itemStyle: { borderColor: "#ffffff", borderWidth: 2, color: "#172033" },
type: "line",
yAxisIndex: 1,
z: 4
}
]
};
}

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import StorageOutlined from "@mui/icons-material/StorageOutlined";
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
@ -33,11 +34,13 @@ export function DatabiHeader({
onRangeChange,
onRegionChange,
onTimeZoneChange,
onViewModeChange,
range,
regionId,
regionOptions = defaultRegionOptions,
timeZone,
timeZoneOptions = TIME_ZONE_OPTIONS
timeZoneOptions = TIME_ZONE_OPTIONS,
viewMode = "report"
}) {
const [openControl, setOpenControl] = useState("");
const headerRef = useRef(null);
@ -77,10 +80,22 @@ export function DatabiHeader({
return (
<header className="databi-header" ref={headerRef}>
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true" />
<div>
<h1>App Data</h1>
<div className="databi-header-left">
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true">
<StorageOutlined fontSize="small" />
</div>
<div>
<h1>数据中心</h1>
</div>
</div>
<div className="view-mode-switch" role="tablist" aria-label="视图切换">
<button aria-selected={viewMode === "report"} className={viewMode === "report" ? "is-active" : ""} onClick={() => onViewModeChange?.("report")} role="tab" type="button">
报表视图
</button>
<button aria-selected={viewMode === "bigscreen"} className={viewMode === "bigscreen" ? "is-active" : ""} onClick={() => onViewModeChange?.("bigscreen")} role="tab" type="button">
大屏 BI
</button>
</div>
</div>
<nav className="screen-switch" aria-label="数据大屏切换">

View File

@ -0,0 +1,537 @@
import { useMemo, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
const reportTabs = [
{ key: "country", label: "国家经营报表" },
{ key: "revenue", label: "收入趋势" },
{ key: "gift", label: "礼物消费" },
{ key: "lucky", label: "幸运礼物" },
{ key: "game", label: "游戏流水" },
{ key: "robot", label: "机器人送礼" }
];
const countryColumns = [
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false },
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
{ key: "registrations", label: "注册", render: (row) => formatOptionalCoin(row.registrations), sortable: false },
{ key: "active_users", label: "活跃用户", render: (row) => formatOptionalCoin(row.active_users), sortable: false },
{ key: "paid_users", label: "付费用户", render: (row) => formatOptionalCoin(row.paid_users), sortable: false },
{ key: "recharge_users", label: "充值用户", render: (row) => formatOptionalCoin(row.recharge_users), sortable: false },
{ key: "recharge_usd_minor", label: "总充值", render: (row) => formatOptionalMoney(row.recharge_usd_minor), sortable: false },
{ key: "new_user_recharge_usd_minor", label: "新用户充值", render: (row) => formatOptionalMoney(row.new_user_recharge_usd_minor), sortable: false },
{ key: "coin_seller_recharge_usd_minor", label: "币商充值", render: (row) => formatOptionalMoney(row.coin_seller_recharge_usd_minor), sortable: false },
{ key: "coin_seller_stock_coin", label: "币商充值金币", render: (row) => formatOptionalCoin(row.coin_seller_stock_coin), sortable: false },
{ key: "coin_seller_transfer_coin", label: "币商出货金币", render: (row) => formatOptionalCoin(row.coin_seller_transfer_coin), sortable: false },
{ key: "google_recharge_usd_minor", label: "谷歌充值", render: (row) => formatOptionalMoney(row.google_recharge_usd_minor), sortable: false },
{ key: "mifapay_recharge_usd_minor", label: "三方充值", render: (row) => formatOptionalMoney(row.mifapay_recharge_usd_minor), sortable: false },
{ key: "game_turnover", label: "游戏流水/利润率", render: (row) => <FlowRateCell rate={row.game_profit_rate} value={row.game_turnover} />, sortable: false },
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
{ key: "coin_total", label: "金币数量(总和)", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
{ key: "platform_grant_coin", label: "平台发放金币", render: (row) => formatOptionalCoin(row.platform_grant_coin), sortable: false },
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
{ key: "avg_mic_online_ms", label: "平均麦上时间", render: (row) => formatDuration(row.avg_mic_online_ms), sortable: false },
{ key: "mic_online_ms", label: "麦上总时长", render: (row) => formatDuration(row.mic_online_ms), sortable: false },
{ key: "arpu_usd_minor", label: "ARPU", render: (row) => formatOptionalMoney(row.arpu_usd_minor), sortable: false },
{ key: "arppu_usd_minor", label: "ARPPU", render: (row) => formatOptionalMoney(row.arppu_usd_minor), sortable: false },
{ key: "payer_rate", label: "付费转化率", render: (row) => formatOptionalPercent(row.payer_rate), sortable: false },
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
];
const revenueColumns = [
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
{ key: "mifapay", label: "MifaPay", render: (row) => formatMoneyFull(row.mifapay) },
{ key: "coin_seller", label: "币商", render: (row) => formatMoneyFull(row.coin_seller) },
{ key: "total", label: "渠道合计", render: (row) => formatMoneyFull(row.total) },
{ key: "lineTotal", label: "总计", render: (row) => formatMoneyFull(row.lineTotal ?? row.total) }
];
const giftColumns = [
{ align: "left", key: "name", label: "礼物", render: (row) => row.name, sortValue: (row) => row.name, type: "text" },
{ key: "value", label: "金币消费", render: (row) => formatCoin(row.value) },
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) },
{ key: "trend_rate", label: "近 7 日", render: (row) => <TrendValue value={row.trend_rate} /> }
];
const luckyColumns = [
{ align: "left", key: "pool_id", label: "奖池", render: (row) => row.pool_id, sortValue: (row) => row.pool_id, type: "text" },
{ key: "turnover", label: "流水", render: (row) => formatCoin(row.turnover) },
{ key: "payout", label: "返奖", render: (row) => formatCoin(row.payout) },
{ key: "profit", label: "利润", render: (row) => formatCoin(row.profit) },
{ key: "payout_rate", label: "返奖率", render: (row) => formatPercent(row.payout_rate) },
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) }
];
const gameColumns = [
{ align: "left", key: "game_id", label: "游戏", render: (row) => row.game_label || row.game_id, sortValue: (row) => row.game_label || row.game_id, type: "text" },
{ key: "turnover_coin", label: "流水 (金币)", render: (row) => formatCoin(row.turnover_coin) },
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) },
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) }
];
const metricColumns = [
{ align: "left", key: "label", label: "指标", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
{ key: "value", label: "数值", render: (row) => row.value, sortable: false },
{ key: "unit", label: "单位", render: (row) => row.unit || "--", sortable: false },
{ key: "caption", label: "对比口径", render: (row) => row.caption || "--", sortable: false },
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
];
export function ReportOverview({ loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
const [activeTab, setActiveTab] = useState("country");
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
return (
<section className="databi-report" aria-label="数据报表">
<section className="report-metric-grid" aria-label="核心经营指标">
{summaryCards.map((card) => (
<ReportMetricCard card={card} key={card.title} loading={loading} onOpenTrend={onMetricTrend} />
))}
</section>
<section className="report-workbench">
<div className="report-tabs" role="tablist" aria-label="报表切换">
{reportTabs.map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? "is-active" : ""}
key={tab.key}
role="tab"
type="button"
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="report-tab-panel" role="tabpanel">
{activeTab === "country" ? <CountryReport loading={loading} rows={model.reportCountryRows} /> : null}
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
{activeTab === "game" ? <GameReport loading={loading} metrics={model.businessKpis} rows={model.gameRanking} /> : null}
{activeTab === "robot" ? <RobotReport loading={loading} rows={model.robotGiftKpis} /> : null}
</div>
</section>
</section>
);
}
function ReportMetricCard({ card, loading, onOpenTrend }) {
const clickable = Boolean(card.trendMetric?.trend?.length && onOpenTrend);
if (loading) {
return (
<article className="report-metric-card is-loading" aria-busy="true">
<span className="skeleton-block report-skeleton-title" />
<span className="skeleton-block report-skeleton-value" />
<span className="skeleton-block report-skeleton-line" />
<span className="skeleton-block report-skeleton-line report-skeleton-line--short" />
</article>
);
}
return (
<article
className={["report-metric-card", clickable ? "report-metric-card--clickable" : ""].filter(Boolean).join(" ")}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => onOpenTrend(card.trendMetric) : undefined}
onKeyDown={clickable ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenTrend(card.trendMetric);
}
} : undefined}
>
<div className="report-metric-head">
<span>{card.title}</span>
{card.delta ? <DeltaText value={card.delta} /> : null}
</div>
<strong>{card.value}</strong>
<div className="report-metric-detail-grid">
{card.details.filter((detail) => detail.label !== card.title).map((detail) => (
<span className="report-metric-detail" key={detail.label}>
<small>{detail.label}</small>
<b>{detail.value}</b>
</span>
))}
</div>
</article>
);
}
function CountryReport({ loading, rows }) {
return (
<ReportPanel>
<ReportTable columns={countryColumns} loading={loading} preserveOrder rows={rows} wide />
</ReportPanel>
);
}
function RevenueReport({ loading, rows }) {
return (
<ReportPanel>
{loading ? (
<div className="report-chart-skeleton" aria-busy="true">
<span className="skeleton-block report-skeleton-line" />
<span className="skeleton-block report-skeleton-chart" />
</div>
) : rows.length ? (
<EChart className="report-revenue-chart" option={createReportRevenueOption(rows)} />
) : (
<div className="report-empty">当前无数据</div>
)}
<ReportTable columns={revenueColumns} defaultSort={{ direction: "asc", key: "label", type: "text" }} loading={loading} rows={rows} />
</ReportPanel>
);
}
function GiftReport({ loading, metrics, rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.value || 0), 0) || 1;
const giftRows = rows.map((row) => ({ ...row, share: Number(row.value || 0) / total }));
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("礼物") || item.label.includes("币商"))} />
<ReportTable columns={giftColumns} defaultSort={{ direction: "desc", key: "value", type: "number" }} loading={loading} rows={giftRows} />
</ReportPanel>
);
}
function LuckyReport({ loading, metrics, onOpenLuckyGiftPools, rows }) {
return (
<ReportPanel
action={(
<button className="report-panel-action" disabled={!rows.length} type="button" onClick={onOpenLuckyGiftPools}>
查看奖池明细
</button>
)}
>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("幸运礼物"))} />
<ReportTable columns={luckyColumns} defaultSort={{ direction: "desc", key: "turnover", type: "number" }} loading={loading} rows={rows} />
</ReportPanel>
);
}
function GameReport({ loading, metrics, rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.turnover_coin || 0), 0) || 1;
const gameRows = rows.map((row) => ({ ...row, share: Number(row.turnover_coin || 0) / total }));
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("游戏"))} />
<ReportTable columns={gameColumns} defaultSort={{ direction: "desc", key: "turnover_coin", type: "number" }} loading={loading} rows={gameRows} />
</ReportPanel>
);
}
function RobotReport({ loading, rows }) {
return (
<ReportPanel>
<ReportMetricTable loading={loading} rows={rows} />
</ReportPanel>
);
}
function ReportPanel({ action, children }) {
return (
<section className="report-panel">
{action ? <div className="report-panel-head">{action}</div> : null}
{children}
</section>
);
}
function ReportMetricTable({ loading, rows }) {
return (
<div className="report-metric-table-block">
<ReportTable columns={metricColumns} loading={loading} rows={rows} />
</div>
);
}
function ReportTable({ columns, defaultSort, loading, preserveOrder = false, rows, wide = false }) {
const sortableDefault = defaultSort || firstSortableColumn(columns);
const [sortState, setSortState] = useState(sortableDefault);
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
const handleSort = (column) => {
if (preserveOrder || column.sortable === false) {
return;
}
setSortState((current) => ({
direction: current?.key === column.key && current.direction === "desc" ? "asc" : "desc",
key: column.key,
type: column.type || "number"
}));
};
return (
<div className="report-table-wrap">
<table className={["report-table", wide ? "report-table--wide" : ""].filter(Boolean).join(" ")}>
<thead>
<tr>
<th>#</th>
{columns.map((column) => {
const isSortable = !preserveOrder && column.sortable !== false;
const isActive = sortState?.key === column.key;
return (
<th className={isSortable ? "is-sortable" : ""} key={column.key}>
{isSortable ? (
<button
className={["report-sort-button", column.align === "left" ? "report-sort-button--left" : "", isActive ? "is-active" : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => handleSort(column)}
>
<span>{column.label}</span>
<i>{isActive ? (sortState.direction === "asc" ? "↑" : "↓") : "↕"}</i>
</button>
) : (
column.label
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
{!loading && sortedRows.map((row, index) => (
<tr className={reportRowClass(row)} key={reportRowKey(row, index)}>
<td>{reportRowIndex(row, index)}</td>
{columns.map((column) => (
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row) : row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function CountryCell({ row }) {
return (
<span className="report-country-cell">
{row.flag ? <i>{row.flag}</i> : null}
<span>{row.country}</span>
</span>
);
}
function DateCell({ row }) {
return <span className="report-date-cell">{row.date_label || "--"}</span>;
}
function FlowRateCell({ rate, value }) {
if (isMissing(value) && isMissing(rate)) {
return <span className="report-placeholder">--</span>;
}
return (
<span className="report-pair-cell">
<b>{formatOptionalCoin(value)}</b>
<small>{formatOptionalPercent(rate)}</small>
</span>
);
}
function TrendValue({ value }) {
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
}
function DeltaText({ value }) {
const text = String(value || "").trim();
if (!text) {
return <span className="report-delta report-delta--empty">--</span>;
}
return <span className={text.startsWith("-") ? "report-delta report-delta--down" : "report-delta"}>{text.startsWith("-") ? "↓" : "↑"} {text.replace(/^[-+]/, "")}</span>;
}
function ReportSkeletonRows({ colSpan }) {
return Array.from({ length: 6 }).map((_, index) => (
<tr className="report-table-skeleton-row" key={index}>
<td colSpan={colSpan}><span className="skeleton-block report-skeleton-line" /></td>
</tr>
));
}
function ReportEmptyRow({ colSpan }) {
return (
<tr className="report-table-empty-row">
<td colSpan={colSpan}>当前无数据</td>
</tr>
);
}
function createSummaryCards(model) {
const totalRecharge = model.kpis[0] || {};
const rechargeSplit = model.kpis[1]?.subMetrics || [];
const newUsers = model.kpis[2] || {};
const activeUsers = model.kpis[3] || {};
const paidSplit = model.kpis[4]?.subMetrics || [];
const arpu = model.kpis[5] || {};
const arppu = model.kpis[6] || {};
const payerRate = model.sideMetrics?.find((item) => item.label === "付费转化率") || {};
return [
{
delta: totalRecharge.delta,
details: [
{ label: "用户充值", value: rechargeSplit[0]?.value || "--" },
{ label: "新用户充值", value: rechargeSplit[1]?.value || "--" }
],
title: "总充值",
trendMetric: totalRecharge,
value: totalRecharge.value || "--",
valueLabel: "总充值"
},
{
delta: activeUsers.delta,
details: [
{ label: "新增用户", value: newUsers.value || "--" },
{ label: "活跃用户", value: activeUsers.value || "--" }
],
title: "活跃用户",
trendMetric: activeUsers,
value: activeUsers.value || "--",
valueLabel: "活跃用户"
},
{
delta: paidSplit[0]?.delta,
details: [
{ label: "付费用户", value: paidSplit[0]?.value || "--" },
{ label: "新增付费", value: paidSplit[1]?.value || "--" }
],
title: "付费转化率",
trendMetric: model.kpis[4],
value: payerRate.value || "--",
valueLabel: "付费转化率"
},
{
delta: arpu.delta,
details: [
{ label: "ARPU", value: arpu.value || "--" },
{ label: "ARPPU", value: arppu.value || "--" }
],
title: "ARPPU",
trendMetric: arpu,
value: arppu.value || "--",
valueLabel: "ARPPU"
}
];
}
function firstSortableColumn(columns) {
const column = columns.find((item) => item.sortable !== false);
return column ? { direction: column.type === "text" ? "asc" : "desc", key: column.key, type: column.type || "number" } : null;
}
function sortRows(rows, sortState, columns) {
if (!sortState?.key) {
return rows;
}
const column = columns.find((item) => item.key === sortState.key);
if (!column) {
return rows;
}
const multiplier = sortState.direction === "asc" ? 1 : -1;
return rows
.map((row, index) => ({ index, row }))
.sort((left, right) => {
const result = compareValues(readSortValue(left.row, column), readSortValue(right.row, column), sortState.type);
return result === 0 ? left.index - right.index : result * multiplier;
})
.map(({ row }) => row);
}
function readSortValue(row, column) {
return column.sortValue ? column.sortValue(row) : row[column.key];
}
function compareValues(leftValue, rightValue, type) {
if (type === "text") {
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
}
const leftNumber = Number(leftValue);
const rightNumber = Number(rightValue);
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
return 0;
}
if (!Number.isFinite(leftNumber)) {
return -1;
}
if (!Number.isFinite(rightNumber)) {
return 1;
}
return leftNumber - rightNumber;
}
function formatSignedPercent(value) {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
}
function reportRowKey(row, index) {
return [row.row_id, row.country, row.name, row.label, row.pool_id, row.game_id, row.platform_code, row.value, index]
.filter((item) => item !== undefined && item !== null && item !== "")
.join(":");
}
function reportRowClass(row) {
if (row.row_type === "total") {
return "report-row-total";
}
if (row.row_type === "date_total") {
return "report-row-date-total";
}
return "";
}
function reportRowIndex(row, index) {
if (row.row_type === "total") {
return "总";
}
if (row.row_type === "date_total") {
return "日";
}
return index + 1;
}
function formatOptionalMoney(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatMoneyFull(value);
}
function formatOptionalCoin(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatCoin(value);
}
function formatOptionalPercent(value) {
return isMissing(value) ? <span className="report-placeholder">--</span> : formatPercent(value);
}
function formatDuration(value) {
if (isMissing(value)) {
return <span className="report-placeholder">--</span>;
}
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function isMissing(value) {
return value === undefined || value === null || Number.isNaN(Number(value));
}

View File

@ -33,16 +33,18 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
const gameProfit = readNumber(source, "game_profit", "gameProfit");
const countryBreakdown = normalizeCountries(source, countryId);
const dailyCountryBreakdown = normalizeDailyCountryBreakdown(source);
const dailySeries = normalizeDailySeries(source);
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
const reportCountryRows = buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source });
return {
appCode,
businessKpis: [
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商转账金币", "coin") }),
coinMetric("币商出货金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商出货金币", "coin") }),
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
@ -50,6 +52,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
],
countryBreakdown,
dailyCountryBreakdown,
funnel: [
{ name: "活跃用户", rate: 1, value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
@ -105,6 +108,8 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
],
luckyGiftPools,
payoutDistribution,
reportCountryRows,
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
revenueSeries,
robotGiftKpis: [
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", { trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin") }),
@ -143,6 +148,15 @@ function readNumber(source, ...keys) {
return 0;
}
function readOptionalNumber(source, ...keys) {
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null) {
return numberValue(source[key]);
}
}
return null;
}
function readDelta(source, ...keys) {
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
for (const key of keys) {
@ -229,6 +243,131 @@ function normalizeCountries(source, countryId) {
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
}
function normalizeDailyCountryBreakdown(source) {
const rows = firstArray(source?.daily_country_breakdown, source?.dailyCountryBreakdown);
if (!rows.length) {
return [];
}
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
return rows.map((item, index) => ({
...normalizeCountryRow(item, index, totalRecharge),
date_label: item.label || item.stat_day || item.statDay || item.day || "",
stat_day: item.stat_day || item.statDay || item.day || ""
}));
}
function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source }) {
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
country: "总计",
date_label: "全部",
row_type: "total"
});
if (!isMultiDayRange(range)) {
return [
totalRow,
...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))
];
}
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
const dayKeys = Array.from(new Set([
...dailySeries.map((item) => item.stat_day || item.label).filter(Boolean),
...dailyCountryBreakdown.map((item) => item.stat_day).filter(Boolean)
])).sort();
if (!dayKeys.length) {
return [
totalRow,
...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))
];
}
const rows = [totalRow];
for (const day of dayKeys) {
const dayCountries = dailyRowsByDay.get(day) || [];
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
rows.push(createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
country: "总计",
date_label: day,
row_type: "date_total",
stat_day: day
}));
if (dayCountries.length) {
rows.push(...dayCountries.map((row) => createReportRow(row, {
date_label: day,
row_type: "country",
stat_day: day
})));
}
}
if (!dailyCountryBreakdown.length) {
rows.push(...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" })));
}
return rows;
}
function createReportRow(row, extra) {
return {
...row,
...extra,
flag: extra.row_type === "country" ? row.flag : "",
row_id: [
extra.row_type,
extra.stat_day || extra.date_label || "",
row.country_id || row.country || "",
row.region_id || ""
].join(":")
};
}
function groupRowsByDay(rows) {
return rows.reduce((groups, row) => {
const day = row.stat_day || row.date_label || "";
if (!day) {
return groups;
}
if (!groups.has(day)) {
groups.set(day, []);
}
groups.get(day).push(row);
return groups;
}, new Map());
}
function summarizeReportRows(rows) {
const fields = [
"active_users",
"coin_seller_recharge_usd_minor",
"coin_seller_stock_coin",
"coin_seller_transfer_coin",
"game_payout",
"game_refund",
"game_turnover",
"gift_coin_spent",
"google_recharge_usd_minor",
"lucky_gift_payout",
"lucky_gift_turnover",
"mifapay_recharge_usd_minor",
"new_user_recharge_usd_minor",
"paid_users",
"recharge_usd_minor",
"recharge_users",
"registrations",
"salary_transfer_coin",
"super_lucky_gift_payout",
"super_lucky_gift_turnover"
];
return fields.reduce((summary, key) => {
summary[key] = rows.reduce((sum, row) => sum + numberValue(row[key]), 0);
return summary;
}, {});
}
function isMultiDayRange(range) {
return Boolean(range?.start && range?.end && range.start !== range.end);
}
function normalizeCountryRow(item, index, totalRecharge) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const countryId = numberValue(item.country_id ?? item.countryId);
@ -238,26 +377,64 @@ function normalizeCountryRow(item, index, totalRecharge) {
const activeUsers = numberValue(item.active_users);
const paidUsers = numberValue(item.paid_users);
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
const gamePayout = readNumber(item, "game_payout", "gamePayout");
const gameRefund = readNumber(item, "game_refund", "gameRefund");
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover - gamePayout - gameRefund);
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover - luckyGiftPayout);
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
const superLuckyGiftProfit = readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
return {
active_users: activeUsers,
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
avg_mic_online_ms: readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs"),
coin_seller_recharge_usd_minor: readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
consumed_coin: readOptionalNumber(item, "consumed_coin", "consumedCoin"),
country: meta?.label || country,
country_id: countryId,
country_code: code || meta?.code || "",
flag: countryFlag(code || meta?.code),
game_payout: gamePayout,
game_profit: gameProfit,
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? ratio(gameProfit, gameTurnover),
game_refund: gameRefund,
game_turnover: gameTurnover,
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
google_recharge_usd_minor: readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
lucky_gift_payout: luckyGiftPayout,
lucky_gift_profit: luckyGiftProfit,
lucky_gift_profit_rate: readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? ratio(luckyGiftProfit, luckyGiftTurnover),
lucky_gift_turnover: luckyGiftTurnover,
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
mic_online_ms: readOptionalNumber(item, "mic_online_ms", "micOnlineMs"),
mifapay_recharge_usd_minor: readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
paid_users: paidUsers,
payer_rate: ratio(paidUsers, activeUsers),
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
recharge_usd_minor: recharge,
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
: ratio(rechargeUsers, activeUsers),
recharge_users: rechargeUsers,
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
share: recharge / totalRecharge,
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
super_lucky_gift_payout: superLuckyGiftPayout,
super_lucky_gift_profit: superLuckyGiftProfit,
super_lucky_gift_profit_rate: readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ?? ratio(superLuckyGiftProfit, superLuckyGiftTurnover),
super_lucky_gift_turnover: superLuckyGiftTurnover,
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
};
}
@ -300,6 +477,7 @@ function normalizeDailySeries(source) {
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
coin_seller: coinSeller,
coin_seller_recharge_usd_minor: coinSeller,
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
game_profit: gameProfit,
game_turnover: gameTurnover,
@ -317,6 +495,7 @@ function normalizeDailySeries(source) {
paid_users: readNumber(item, "paid_users", "paidUsers"),
recharge_usd_minor: recharge,
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
total: recharge,
user_recharge_usd_minor: userRecharge

View File

@ -25,14 +25,18 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => {
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
const model = createDashboardModel({
coin_seller_recharge_usd_minor: 200,
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
salary_transfer_coin: 88_000,
country_breakdown: [
{
coin_seller_recharge_usd_minor: 200,
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
country: "阿富汗",
google_recharge_usd_minor: 150,
recharge_usd_minor: 150
recharge_usd_minor: 150,
salary_transfer_coin: 88_000
}
],
daily_series: [
@ -48,9 +52,9 @@ test("includes coin seller stock recharge in USD total and keeps seller transfer
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(metricValue(model, "总充值")).toBe("$4");
expect(metricValue(model, "币商转账金币")).toBe("160,000");
expect(metricValue(model, "币商出货金币")).toBe("160,000");
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350 }));
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_stock_coin: 400_000, coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350, salary_transfer_coin: 88_000 }));
});
test("exposes new user KPI and removes visitor stage from funnel", () => {
@ -124,6 +128,55 @@ test("uses registration count instead of legacy visitors in country breakdown",
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
});
test("builds report rows with range total, daily totals, and daily country rows", () => {
const model = createDashboardModel({
country_breakdown: [
{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }
],
daily_country_breakdown: [
{
active_users: 10,
coin_seller_stock_coin: 800,
coin_seller_transfer_coin: 500,
country: "巴西",
country_id: 86,
game_turnover: 300,
lucky_gift_turnover: 200,
recharge_usd_minor: 100,
salary_transfer_coin: 700,
stat_day: "2026-06-05",
super_lucky_gift_turnover: 80,
super_lucky_gift_payout: 20
},
{
active_users: 20,
country: "巴西",
country_id: 86,
recharge_usd_minor: 200,
stat_day: "2026-06-06"
}
],
daily_series: [
{ active_users: 10, label: "2026-06-05", recharge_usd_minor: 100, stat_day: "2026-06-05" },
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" }
],
recharge_usd_minor: 300
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "country", "date_total", "country"]);
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
expect(model.reportCountryRows[1]).toEqual(expect.objectContaining({ country: "总计", date_label: "2026-06-05" }));
expect(model.reportCountryRows[2]).toEqual(expect.objectContaining({
coin_seller_stock_coin: 800,
coin_seller_transfer_coin: 500,
country: "巴西",
game_turnover: 300,
lucky_gift_turnover: 200,
salary_transfer_coin: 700,
super_lucky_gift_profit: 60
}));
});
function metricValue(model, label) {
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
}

View File

@ -5,4 +5,5 @@
@import "./panels.css";
@import "./charts.css";
@import "./tables.css";
@import "./report.css";
@import "./responsive.css";

View File

@ -50,24 +50,11 @@
box-shadow: inset 0 0 12px rgba(39, 228, 245, 0.16);
}
.brand-emblem::before {
width: 19px;
height: 19px;
border: 2px solid #27e4f5;
border-radius: 50%;
background:
linear-gradient(90deg, transparent 45%, #27e4f5 45% 55%, transparent 55%),
linear-gradient(transparent 45%, #27e4f5 45% 55%, transparent 55%);
box-shadow: 0 0 12px rgba(39, 228, 245, 0.55);
content: "";
}
.brand-emblem::after {
position: absolute;
inset: 7px;
border: 1px solid rgba(39, 228, 245, 0.55);
border-radius: 50%;
content: "";
.brand-emblem svg {
width: 23px;
height: 23px;
color: #27e4f5;
filter: drop-shadow(0 0 8px rgba(39, 228, 245, 0.38));
}
.brand-lockup h1 {

View File

@ -0,0 +1,713 @@
.databi-header-left {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
gap: 20px;
}
.view-mode-switch {
display: inline-flex;
flex: 0 0 auto;
gap: 3px;
padding: 3px;
border: 1px solid rgba(61, 141, 190, 0.42);
border-radius: 8px;
background: rgba(7, 28, 47, 0.72);
}
.view-mode-switch button {
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 6px;
background: transparent;
color: #9eb7ce;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 760;
}
.view-mode-switch button:hover,
.view-mode-switch button.is-active {
background: rgba(37, 216, 245, 0.18);
color: #e9f6ff;
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
}
.databi-screen--report {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.databi-shell--report {
min-width: 1180px;
min-height: 100vh;
padding: 0 24px 28px;
background: #f5f7fb;
color: #172033;
}
.databi-shell--report .databi-header {
height: auto;
min-height: 72px;
flex-wrap: wrap;
align-content: center;
row-gap: 10px;
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #e4ebf3;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.05);
}
.databi-shell--report .header-controls {
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.databi-shell--report .brand-emblem {
border-color: #b9dcf4;
background: #eef8ff;
box-shadow: inset 0 0 0 1px rgba(22, 136, 217, 0.04);
}
.databi-shell--report .brand-emblem svg {
color: #1688d9;
filter: none;
}
.databi-shell--report .brand-lockup h1 {
color: #172033;
}
.databi-shell--report .view-mode-switch,
.databi-shell--report .screen-switch,
.databi-shell--report .game-switch {
border-color: #d8e5f0;
background: #eef4fa;
}
.databi-shell--report .view-mode-switch button,
.databi-shell--report .screen-switch button,
.databi-shell--report .game-switch button {
color: #5f6f86;
}
.databi-shell--report .view-mode-switch button:hover,
.databi-shell--report .view-mode-switch button.is-active,
.databi-shell--report .screen-switch button:hover,
.databi-shell--report .screen-switch button.is-active,
.databi-shell--report .game-switch button:hover,
.databi-shell--report .game-switch button.is-active {
background: #ffffff;
color: #126fb2;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.databi-shell--report .filter-trigger,
.databi-shell--report .date-range-trigger {
border-color: #d8e5f0;
background: #ffffff;
color: #172033;
}
.databi-shell--report .filter-trigger:hover,
.databi-shell--report .filter-trigger[aria-expanded="true"],
.databi-shell--report .date-range-trigger:hover,
.databi-shell--report .date-range-trigger[aria-expanded="true"] {
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
}
.databi-shell--report .filter-label,
.databi-shell--report .date-range-label {
background: #ffffff;
color: #718198;
}
.databi-shell--report .filter-trigger:hover .filter-label,
.databi-shell--report .filter-trigger[aria-expanded="true"] .filter-label,
.databi-shell--report .date-range-trigger:hover .date-range-label,
.databi-shell--report .date-range-trigger[aria-expanded="true"] .date-range-label,
.databi-shell--report .control-glyph,
.databi-shell--report .filter-label .filter-icon {
color: #1688d9;
}
.databi-shell--report .filter-value,
.databi-shell--report .date-range-values b {
color: #172033;
}
.databi-shell--report .filter-icon,
.databi-shell--report .filter-chevron,
.databi-shell--report .date-range-chevron,
.databi-shell--report .date-range-label svg {
color: #718198;
}
.databi-shell--report .date-range-values i {
color: #8c9aab;
}
.databi-shell--report .filter-popover,
.databi-shell--report .date-time-popover {
border-color: #d8e5f0;
background: #ffffff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
}
.databi-shell--report .filter-popover button,
.databi-shell--report .date-time-shortcuts button,
.databi-shell--report .date-time-fields button,
.databi-shell--report .calendar-toolbar button,
.databi-shell--report .calendar-day,
.databi-shell--report .time-column button,
.databi-shell--report .date-time-footer button {
color: #34445a;
}
.databi-shell--report .filter-popover button:hover,
.databi-shell--report .filter-popover button.is-active,
.databi-shell--report .date-time-shortcuts button:hover,
.databi-shell--report .date-time-shortcuts button.is-active,
.databi-shell--report .calendar-day:hover,
.databi-shell--report .time-column button:hover,
.databi-shell--report .time-column button.is-active {
background: #eef7ff;
color: #126fb2;
}
.databi-shell--report .filter-empty,
.databi-shell--report .filter-search span,
.databi-shell--report .date-time-fields span,
.databi-shell--report .date-time-footer span,
.databi-shell--report .time-column > span,
.databi-shell--report .calendar-weekday {
color: #718198;
}
.databi-shell--report .filter-search input,
.databi-shell--report .date-time-fields button {
border-color: #d8e5f0;
background: #ffffff;
color: #172033;
}
.databi-shell--report .filter-search input:focus,
.databi-shell--report .date-time-fields button.is-active {
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.1);
}
.databi-shell--report .calendar-toolbar,
.databi-shell--report .calendar-pane,
.databi-shell--report .date-time-fields,
.databi-shell--report .date-time-footer,
.databi-shell--report .time-column,
.databi-shell--report .time-column > span {
border-color: #e4ebf3;
}
.databi-shell--report .calendar-toolbar strong,
.databi-shell--report .date-time-fields b {
color: #172033;
}
.databi-shell--report .calendar-day.is-muted {
color: #b5c0cd;
}
.databi-shell--report .calendar-day.is-start,
.databi-shell--report .calendar-day.is-end,
.databi-shell--report .calendar-day.is-active {
background: #1688d9;
color: #ffffff;
}
.databi-shell--report .calendar-day.is-between {
background: #e9f5ff;
}
.databi-shell--report .date-time-footer button {
border-color: #1688d9;
background: #1688d9;
color: #ffffff;
}
.databi-report {
display: grid;
align-content: start;
gap: 16px;
min-width: 0;
}
.report-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.report-metric-card {
display: grid;
min-width: 0;
min-height: 142px;
align-content: start;
gap: 8px;
padding: 16px 18px;
border: 1px solid #dfe8f2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.report-metric-card--clickable {
cursor: pointer;
}
.report-metric-card--clickable:hover,
.report-metric-card--clickable:focus-visible {
border-color: #1688d9;
box-shadow: 0 0 0 3px rgba(22, 136, 217, 0.08), 0 8px 20px rgba(15, 23, 42, 0.06);
outline: none;
}
.report-metric-head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.report-metric-head > span:first-child {
overflow: hidden;
color: #53647b;
font-size: 13px;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-metric-card > strong {
overflow: hidden;
color: #172033;
font-size: 28px;
font-weight: 780;
line-height: 1.14;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-metric-primary-label {
color: #7b8a9d;
font-size: 12px;
}
.report-metric-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 4px;
}
.report-metric-detail {
display: grid;
min-width: 0;
gap: 2px;
}
.report-metric-detail small {
overflow: hidden;
color: #8a98aa;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-metric-detail b {
overflow: hidden;
color: #243044;
font-size: 14px;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-workbench {
min-width: 0;
overflow: hidden;
border: 1px solid #dfe8f2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.report-tabs {
display: flex;
min-width: 0;
gap: 4px;
padding: 10px 12px 0;
border-bottom: 1px solid #e4ebf3;
overflow-x: auto;
}
.report-tabs button {
height: 38px;
flex: 0 0 auto;
padding: 0 14px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: #65748a;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 760;
}
.report-tabs button:hover,
.report-tabs button.is-active {
border-bottom-color: #1688d9;
color: #126fb2;
}
.report-tab-panel {
min-width: 0;
}
.report-panel {
display: grid;
min-width: 0;
gap: 14px;
padding: 16px;
}
.report-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.report-panel-head h2 {
margin: 0;
color: #172033;
font-size: 18px;
font-weight: 780;
}
.report-panel-action {
height: 32px;
padding: 0 14px;
border: 1px solid #c7d7e7;
border-radius: 8px;
background: #ffffff;
color: #126fb2;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 760;
}
.report-panel-action:hover {
border-color: #1688d9;
background: #eef7ff;
}
.report-panel-action:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.report-metric-table-block {
min-width: 0;
}
.report-table-wrap {
min-width: 0;
overflow: auto;
border: 1px solid #e4ebf3;
border-radius: 8px;
}
.report-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
background: #ffffff;
}
.report-table--wide {
min-width: 2860px;
}
.report-table th,
.report-table td {
height: 38px;
padding: 0 12px;
border-bottom: 1px solid #edf2f7;
color: #34445a;
font-size: 13px;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.report-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
color: #65748a;
font-size: 12px;
font-weight: 760;
}
.report-table th:first-child,
.report-table td:first-child {
width: 48px;
color: #8a98aa;
text-align: center;
}
.report-table td.is-left {
text-align: left;
}
.report-table tbody tr:hover td {
background: #f7fbff;
}
.report-row-total td {
background: #eef7ff;
color: #172033;
font-weight: 780;
}
.report-row-date-total td {
background: #f7fafc;
color: #243044;
font-weight: 760;
}
.report-row-total:hover td,
.report-row-date-total:hover td {
background: #e9f5ff;
}
.report-sort-button {
display: flex;
width: 100%;
height: 38px;
align-items: center;
justify-content: flex-end;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.report-sort-button--left {
justify-content: flex-start;
}
.report-sort-button:hover,
.report-sort-button.is-active {
color: #126fb2;
}
.report-sort-button i {
color: #9aa7b8;
font-size: 11px;
font-style: normal;
}
.report-country-cell {
display: inline-flex;
max-width: 220px;
min-width: 0;
align-items: center;
gap: 8px;
}
.report-country-cell i {
flex: 0 0 auto;
font-style: normal;
}
.report-country-cell span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.report-date-cell {
color: #53647b;
font-weight: 700;
}
.report-pair-cell {
display: inline-flex;
min-width: 118px;
align-items: baseline;
justify-content: flex-end;
gap: 8px;
}
.report-pair-cell b {
color: #243044;
font-size: 13px;
font-weight: 760;
}
.report-pair-cell small {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.report-placeholder {
color: #94a3b8;
}
.report-trend-up,
.report-delta {
color: #0f9b72;
font-weight: 760;
}
.report-trend-down,
.report-delta--down {
color: #d97706;
font-weight: 760;
}
.report-delta--empty {
color: #94a3b8;
}
.report-table-skeleton-row td,
.report-table-empty-row td {
text-align: center;
}
.report-table-empty-row td {
height: 96px;
color: #8a98aa;
}
.report-empty {
display: grid;
min-height: 240px;
place-items: center;
border: 1px dashed #d8e5f0;
border-radius: 8px;
color: #8a98aa;
}
.report-revenue-chart {
width: 100%;
height: 300px;
border: 1px solid #e4ebf3;
border-radius: 8px;
}
.report-chart-skeleton {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #e4ebf3;
border-radius: 8px;
}
.report-skeleton-title {
width: 112px;
height: 14px;
}
.report-skeleton-value {
width: 160px;
height: 32px;
}
.report-skeleton-line {
width: 100%;
height: 16px;
}
.report-skeleton-line--short {
width: 62%;
}
.report-skeleton-chart {
width: 100%;
height: 240px;
}
.databi-shell--report .databi-panel,
.databi-shell--report .mini-stat,
.databi-shell--report .metric-card {
border-color: #dfe8f2;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.databi-shell--report .panel-title,
.databi-shell--report .self-game-title strong,
.databi-shell--report .metric-content strong,
.databi-shell--report .risk-user-copy strong,
.databi-shell--report .definition-row strong {
color: #172033;
}
.databi-shell--report .panel-info {
border-color: #c7d7e7;
color: #6b7a90;
}
.databi-shell--report .metric-label-row,
.databi-shell--report .mini-stat > span,
.databi-shell--report .section-label,
.databi-shell--report .rank-title,
.databi-shell--report .self-game-title span,
.databi-shell--report .definition-row span,
.databi-shell--report .risk-user-copy small {
color: #65748a;
}
.databi-shell--report .metric-title-icon,
.databi-shell--report .metric-unit-icon {
color: #1688d9;
}
.databi-shell--report .metric-delta {
border-top-color: #e4ebf3;
color: #7b8a9d;
}
.databi-shell--report .self-game-table th,
.databi-shell--report .self-game-table td {
border-bottom-color: #edf2f7;
color: #34445a;
}
.databi-shell--report .self-game-table th {
background: #f8fafc;
color: #65748a;
}
.databi-shell--report .definition-row {
border-color: #e4ebf3;
background: #f8fafc;
}
.databi-shell--report .panel-empty {
color: #8a98aa;
}

12
money/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="money-root"></div>
<script type="module" src="/money/src/main.jsx"></script>
</body>
</html>

319
money/src/MoneyApp.jsx Normal file
View File

@ -0,0 +1,319 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMoneySession } from "./api.js";
import { CountryChannelPanel } from "./components/CountryChannelPanel.jsx";
import { MoneyHeader } from "./components/MoneyHeader.jsx";
import { MoneyOverview } from "./components/MoneyOverview.jsx";
import { MONEY_VIEWS, MoneySidebar } from "./components/MoneySidebar.jsx";
import { OperatorPerformancePanel } from "./components/OperatorPerformancePanel.jsx";
import { PaymentLinkPanel } from "./components/PaymentLinkPanel.jsx";
import { PerformanceTable } from "./components/PerformanceTable.jsx";
import {
filterMoneyModules,
moneyAppOptions,
moneyCountries,
moneyOperators,
summarizeMoneyModules
} from "./data.js";
import {
appOptionsFromMasterData,
countryOptionsFromMasterData,
fetchMoneyMasterData,
regionOptionsFromMasterData
} from "./masterDataApi.js";
const emptyMasterData = { apps: [], countries: [], error: "", loading: false, modules: [], performanceSummary: {}, regions: [], scopes: [] };
export function MoneyApp() {
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
const [masterDataState, setMasterDataState] = useState(emptyMasterData);
const [activeView, setActiveView] = useState("overview");
const [mode, setMode] = useState("finance");
const [activeOperatorId, setActiveOperatorId] = useState("sarah-ali");
const [appCode, setAppCode] = useState("");
const [countryCode, setCountryCode] = useState("");
const [regionId, setRegionId] = useState("");
const [operatorKeyword, setOperatorKeyword] = useState("");
const [links, setLinks] = useState([]);
const [toast, setToast] = useState("");
useEffect(() => {
let mounted = true;
fetchMoneySession()
.then((session) => {
if (mounted) {
setSessionState({ error: "", loading: false, session });
}
})
.catch((err) => {
if (mounted) {
setSessionState({ error: err.message || "会话校验失败", loading: false, session: null });
}
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!sessionState.session?.canViewMoney) {
return undefined;
}
let mounted = true;
setMasterDataState((current) => ({ ...current, error: "", loading: true }));
fetchMoneyMasterData()
.then((data) => {
if (mounted) {
setMasterDataState({ ...data, loading: false });
}
})
.catch((err) => {
if (mounted) {
setMasterDataState({ ...emptyMasterData, error: err.message || "主数据加载失败", loading: false });
}
});
return () => {
mounted = false;
};
}, [sessionState.session?.canViewMoney]);
const appOptions = useMemo(() => appOptionsFromMasterData(masterDataState.apps, moneyAppOptions), [masterDataState.apps]);
const countryOptions = useMemo(
() => countryOptionsFromMasterData(masterDataState.countries, moneyCountries),
[masterDataState.countries]
);
const regionOptions = useMemo(() => regionOptionsFromMasterData(masterDataState.regions), [masterDataState.regions]);
useEffect(() => {
if (appCode && !hasOption(appOptions, appCode)) {
setAppCode("");
}
}, [appCode, appOptions]);
useEffect(() => {
if (countryCode && !hasOption(countryOptions, countryCode)) {
setCountryCode("");
}
}, [countryCode, countryOptions]);
useEffect(() => {
if (regionId && !hasOption(regionOptions, regionId)) {
setRegionId("");
}
}, [regionId, regionOptions]);
const filteredModules = useMemo(
() =>
filterMoneyModules({
activeOperatorId,
appCode,
countryCode,
mode,
modules: masterDataState.modules,
operatorKeyword: mode === "finance" ? operatorKeyword : "",
regionId,
regions: masterDataState.regions
}),
[activeOperatorId, appCode, countryCode, masterDataState.modules, masterDataState.regions, mode, operatorKeyword, regionId]
);
const summary = useMemo(() => summarizeMoneyModules(filteredModules), [filteredModules]);
const visibleModuleIds = useMemo(() => new Set(filteredModules.map((module) => module.id)), [filteredModules]);
const visibleLinks = useMemo(() => links.filter((link) => visibleModuleIds.has(link.moduleId)), [links, visibleModuleIds]);
const operators = useMemo(() => operatorsFromModules(masterDataState.modules), [masterDataState.modules]);
const activeOperator = operators.find((operator) => operator.id === activeOperatorId) || operators[0] || moneyOperators[0];
const view = MONEY_VIEWS.find((item) => item.id === activeView) || MONEY_VIEWS[0];
const resetFilters = () => {
setAppCode("");
setCountryCode("");
setRegionId("");
setOperatorKeyword("");
};
const showToast = (message) => {
setToast(message);
window.setTimeout(() => setToast(""), 1600);
};
const copyLink = async (value) => {
await copyText(value);
showToast("链接已复制");
};
const addPaymentLink = (link) => {
setLinks((current) => [link, ...current.filter((item) => (item.orderId || item.id) !== (link.orderId || link.id))]);
showToast("支付链接已创建");
};
const exportReport = () => {
downloadTextFile(createCsv(filteredModules), `hyapp-money-${Date.now()}.csv`);
showToast("财务报表已导出");
};
if (sessionState.loading) {
return <StateScreen title="正在进入财务系统" />;
}
if (sessionState.error) {
return <StateScreen title={sessionState.error} />;
}
if (!sessionState.session?.canViewMoney) {
return <StateScreen title="无 money:view" />;
}
return (
<main className="money-shell">
<MoneySidebar
activeOperator={activeOperator}
activeView={activeView}
mode={mode}
onModeChange={setMode}
onViewChange={setActiveView}
session={sessionState.session}
summary={summary}
/>
<section className="money-main">
<MoneyHeader
activeOperatorId={activeOperatorId}
appCode={appCode}
appOptions={appOptions}
countryCode={countryCode}
countryOptions={countryOptions}
masterDataError={masterDataState.error}
masterDataLoading={masterDataState.loading}
mode={mode}
onActiveOperatorChange={setActiveOperatorId}
onAppChange={setAppCode}
onCountryChange={setCountryCode}
onExport={exportReport}
onOperatorKeywordChange={setOperatorKeyword}
onRegionChange={setRegionId}
onReset={resetFilters}
operatorKeyword={operatorKeyword}
operators={operators}
regionId={regionId}
regionOptions={regionOptions}
view={view}
/>
<div className="money-content">
{renderContent(activeView, {
copyLink,
countries: masterDataState.countries,
filteredModules,
onPaymentLinkCreated: addPaymentLink,
regions: masterDataState.regions,
summary,
visibleLinks
})}
</div>
</section>
{toast ? <div className="money-toast">{toast}</div> : null}
</main>
);
}
function renderContent(activeView, { copyLink, countries, filteredModules, onPaymentLinkCreated, regions, summary, visibleLinks }) {
if (activeView === "modules") {
return <PerformanceTable meta={`${filteredModules.length} 个区域`} modules={filteredModules} summary={summary} title="区域绩效" />;
}
if (activeView === "operators") {
return <OperatorPerformancePanel modules={filteredModules} />;
}
if (activeView === "payments") {
return <PaymentLinkPanel links={visibleLinks} modules={filteredModules} onCopy={copyLink} onCreated={onPaymentLinkCreated} />;
}
if (activeView === "countries") {
return <CountryChannelPanel countries={countries} links={visibleLinks} modules={filteredModules} regions={regions} />;
}
return <MoneyOverview links={visibleLinks} modules={filteredModules} summary={summary} />;
}
function hasOption(options, value) {
return options.some(([optionValue]) => String(optionValue) === String(value));
}
function StateScreen({ detail = "", title }) {
return (
<main className="money-shell money-shell--state">
<section className="money-state">
<h1>{title}</h1>
{detail ? <p>{detail}</p> : null}
</section>
</main>
);
}
function createCsv(modules) {
const header = ["区域", "负责人", "国家", "充值USD", "流水USD", "存量工资USD", "新增", "日活", "当日目标USD", "当月目标USD"];
const rows = modules.map((module) => [
module.moduleName,
module.operatorName,
module.countryName,
module.rechargeUsd,
module.revenueUsd,
module.salaryUsd,
module.newUsers,
module.dau,
module.dayTargetUsd,
module.monthTargetUsd
]);
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
}
function operatorsFromModules(modules) {
const seen = new Set();
return modules
.map((module) => ({
department: module.department || "",
id: module.operatorId,
label: module.operatorName || module.operatorId
}))
.filter((operator) => {
if (!operator.id || seen.has(operator.id)) {
return false;
}
seen.add(operator.id);
return true;
});
}
function csvCell(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// HTTP Clipboard API退
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}

99
money/src/api.js Normal file
View File

@ -0,0 +1,99 @@
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
const TOKEN_KEY = "hyapp-admin.access-token";
const APP_CODE_KEY = "hyapp-admin.app-code";
const APP_CODE_HEADER = "X-App-Code";
const MONEY_PERMISSION = "money:view";
const DEFAULT_APP_CODE = "lalu";
export async function fetchMoneySession() {
const session = await adminApiGet("/v1/auth/me");
return {
...session,
canViewMoney: Array.isArray(session.permissions) && session.permissions.includes(MONEY_PERMISSION)
};
}
export function adminApiGet(path, options = {}) {
return adminApiRequest(path, { ...options, method: "GET" });
}
export async function adminApiRequest(path, options = {}) {
const response = await fetch(adminApiUrl(path, options.query), {
body: requestBody(options.body),
credentials: "include",
headers: requestHeaders(options),
method: options.method || (options.body === undefined ? "GET" : "POST")
});
const payload = await readJSON(response);
if (response.status === 401 || Number(payload?.code) === 40100) {
redirectToLogin();
throw new Error("登录已失效");
}
if (!response.ok || isApiFailure(payload)) {
throw new Error(payload.message || response.statusText || "会话校验失败");
}
return Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : payload;
}
function requestHeaders(options = {}) {
const headers = { ...(options.headers || {}) };
const token = window.localStorage.getItem(TOKEN_KEY);
const appCode = window.localStorage.getItem(APP_CODE_KEY) || DEFAULT_APP_CODE;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (appCode && !headers[APP_CODE_HEADER]) {
headers[APP_CODE_HEADER] = appCode;
}
if (options.body !== undefined && !(options.body instanceof FormData) && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
return headers;
}
function requestBody(body) {
if (body === undefined) {
return undefined;
}
return body instanceof FormData ? body : JSON.stringify(body);
}
function adminApiUrl(path, query) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
async function readJSON(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, message: text };
}
}
function isApiFailure(payload) {
const code = payload?.code;
const errorCode = payload?.errorCode;
return (
(typeof code === "number" && code !== 0) ||
(typeof code === "string" && code !== "" && code !== "0" && code !== "OK") ||
(typeof errorCode === "number" && errorCode !== 0) ||
(typeof errorCode === "string" && errorCode !== "" && errorCode !== "0" && errorCode !== "OK")
);
}
function redirectToLogin() {
window.localStorage.removeItem(TOKEN_KEY);
if (window.location.pathname !== "/login") {
window.location.replace("/login");
}
}

View File

@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
import { BarChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([BarChart, GridComponent, LegendComponent, LineChart, TooltipComponent, CanvasRenderer]);
export function EChart({ className = "", option }) {
const elementRef = useRef(null);
const chartRef = useRef(null);
const optionRef = useRef(option);
useEffect(() => {
optionRef.current = option;
chartRef.current?.setOption(option, true);
}, [option]);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return undefined;
}
const chart = echarts.init(element, null, { renderer: "canvas" });
chartRef.current = chart;
chart.setOption(optionRef.current, true);
const resize = () => chart.resize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
observer?.observe(element);
window.addEventListener("resize", resize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", resize);
chart.dispose();
chartRef.current = null;
};
}, []);
return <div className={["money-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
}

View File

@ -0,0 +1,101 @@
import { availableProvidersForCountry, moneyCountries, summarizeMoneyModules } from "../data.js";
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function CountryChannelPanel({ countries = [], links, modules, regions = [] }) {
const rows = countryRows(countries, modules, links, regions)
.map((country) => {
const countryModules = modules.filter((module) => module.countryCode === country.code);
const countryLinks = links.filter((link) => link.countryCode === country.code);
const summary = summarizeMoneyModules(countryModules);
return {
...country,
linkCount: countryLinks.length,
modules: countryModules,
paidLinkCount: countryLinks.filter((link) => link.status === "paid").length,
providers: availableProvidersForCountry(country.code),
summary
};
})
.filter((country) => country.modules.length > 0 || country.linkCount > 0)
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
return (
<section className="money-country-grid">
{rows.map((country) => (
<article className="money-panel money-country-card" key={country.code}>
<div className="money-country-head">
<div>
<span>{country.region}</span>
<h2>{country.label}</h2>
</div>
<strong>{country.currency}</strong>
</div>
<div className="money-country-metrics">
<Metric label="充值" value={formatUsd(country.summary.rechargeUsd)} tone="success" />
<Metric label="链接转化" value={formatPercent(ratio(country.paidLinkCount, country.linkCount))} tone="primary" />
<Metric label="区域" value={formatNumber(country.modules.length)} tone="neutral" />
</div>
<div className="money-provider-list">
{country.providers.map((provider) => (
<span key={provider.code}>{provider.label}</span>
))}
</div>
<div className="money-country-foot">
<span>{formatNumber(country.summary.newUsers)} 新增</span>
<span>{formatNumber(country.summary.dau)} DAU</span>
</div>
</article>
))}
</section>
);
}
function countryRows(countries, modules, links, regions) {
const fallbackCountries = new Map(moneyCountries.map((country) => [country.code, country]));
const realCountries = new Map(countries.map((country) => [country.countryCode, country]));
const codes = new Set([
...moneyCountries.map((country) => country.code),
...modules.map((module) => module.countryCode),
...links.map((link) => link.countryCode)
]);
return [...codes]
.filter((code) => code && code !== "ALL")
.map((code) => {
const fallback = fallbackCountries.get(code) || {};
const real = realCountries.get(code) || {};
return {
code,
currency: fallback.currency || real.currencyCode || "-",
label: real.countryDisplayName || real.countryName || fallback.label || code,
region: regionNameForCountry(code, regions) || fallback.region || "-"
};
});
}
function regionNameForCountry(countryCode, regions) {
const normalizedCountryCode = String(countryCode || "").toUpperCase();
const region = regions.find((item) =>
(item.countries || []).some((code) => String(code || "").toUpperCase() === normalizedCountryCode)
);
if (!region) {
return "";
}
return [region.name, region.regionCode].filter(Boolean).join(" · ");
}
function Metric({ label, tone, value }) {
return (
<div className={`money-country-metric money-country-metric--${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function ratio(value, total) {
if (!total) {
return 0;
}
return (Number(value) / Number(total)) * 100;
}

View File

@ -0,0 +1,90 @@
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
export function MoneyHeader({
activeOperatorId,
appCode,
appOptions,
countryCode,
countryOptions,
masterDataError,
masterDataLoading,
mode,
onActiveOperatorChange,
onAppChange,
onCountryChange,
onExport,
onOperatorKeywordChange,
onRegionChange,
onReset,
operatorKeyword,
operators,
regionId,
regionOptions,
view
}) {
return (
<header className="money-header">
<div className="money-header-title">
<h1>{view.title}</h1>
</div>
<div className="money-header-tools">
<div className="money-filter-group">
<FilterSelect disabled={masterDataLoading && appOptions.length <= 1} label="App" options={appOptions} value={appCode} onChange={onAppChange} />
<FilterSelect
disabled={masterDataLoading && countryOptions.length <= 1}
label="国家"
options={countryOptions}
value={countryCode}
onChange={onCountryChange}
/>
<FilterSelect
disabled={masterDataLoading && regionOptions.length <= 1}
label="区域"
options={regionOptions}
value={regionId}
onChange={onRegionChange}
/>
{mode === "finance" ? (
<label className="money-filter money-filter--search">
<span>运营人员</span>
<input placeholder="姓名 / 部门" value={operatorKeyword} onChange={(event) => onOperatorKeywordChange(event.target.value)} />
</label>
) : (
<FilterSelect
label="当前运营"
options={operators.map((operator) => [operator.id, operator.label])}
value={activeOperatorId}
onChange={onActiveOperatorChange}
/>
)}
{masterDataError ? <span className="money-filter-status">主数据加载失败</span> : null}
</div>
<div className="money-actions">
<button className="money-button" onClick={onReset} type="button">
<RestartAltOutlined fontSize="small" />
重置
</button>
<button className="money-button money-button--primary" onClick={onExport} type="button">
<FileDownloadOutlined fontSize="small" />
导出
</button>
</div>
</div>
</header>
);
}
function FilterSelect({ disabled = false, label, onChange, options, value }) {
return (
<label className="money-filter">
<span>{label}</span>
<select disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, optionLabel]) => (
<option key={optionValue || "all"} value={optionValue}>
{optionLabel}
</option>
))}
</select>
</label>
);
}

View File

@ -0,0 +1,23 @@
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function MoneyKpis({ summary }) {
const cards = [
["总充值", formatUsd(summary.rechargeUsd), `ARPPU ${formatUsd(summary.arppuUsd)}`, "success"],
["App 流水", formatUsd(summary.revenueUsd), `毛利 ${formatUsd(summary.grossProfitUsd)}`, "primary"],
["存量工资", formatUsd(summary.salaryUsd), `日目标 ${formatPercent(summary.dayProgress)}`, "warning"],
["新增 / 日活", formatNumber(summary.newUsers), `DAU ${formatNumber(summary.dau)}`, "info"],
["支付链接转化", formatPercent(summary.linkConversion), `${formatNumber(summary.paidLinkCount)} / ${formatNumber(summary.totalLinkCount)} 已支付`, "neutral"]
];
return (
<section className="money-kpis">
{cards.map(([label, value, sub, tone]) => (
<article className={`money-kpi money-kpi--${tone}`} key={label}>
<span>{label}</span>
<strong>{value}</strong>
<small>{sub}</small>
</article>
))}
</section>
);
}

View File

@ -0,0 +1,85 @@
import { MoneyKpis } from "./MoneyKpis.jsx";
import { MoneyTrendPanel } from "./MoneyTrendPanel.jsx";
import { formatDateTime, formatNumber, formatPercent, formatUsd } from "../format.js";
import { paymentStatusLabel } from "../data.js";
export function MoneyOverview({ links, modules, summary }) {
const topModules = [...modules].sort((a, b) => b.rechargeUsd - a.rechargeUsd).slice(0, 4);
const recentLinks = links.slice(0, 4);
return (
<>
<MoneyKpis summary={summary} />
<MoneyTrendPanel summary={summary} />
<section className="money-workbench-grid">
<article className="money-panel money-focus-panel">
<div className="money-panel-head">
<div>
<h2>高价值区域</h2>
<span>Top 4</span>
</div>
<strong>{formatUsd(summary.grossProfitUsd)}</strong>
</div>
<div className="money-focus-list">
{topModules.map((module, index) => (
<div className="money-focus-row" key={module.id}>
<b>{String(index + 1).padStart(2, "0")}</b>
<span>
<strong>{module.moduleName}</strong>
<small>{module.operatorName} · {module.countryName}</small>
</span>
<em>{formatUsd(module.rechargeUsd)}</em>
</div>
))}
</div>
</article>
<article className="money-panel money-focus-panel">
<div className="money-panel-head">
<div>
<h2>支付链接动态</h2>
<span>Last 4</span>
</div>
<strong>{formatPercent(summary.linkConversion)}</strong>
</div>
<div className="money-focus-list">
{recentLinks.map((link) => (
<div className="money-focus-row" key={link.id}>
<b>{link.countryCode}</b>
<span>
<strong>{link.moduleName}</strong>
<small>{formatDateTime(link.createdAtMs)} · {paymentStatusLabel(link.status)}</small>
</span>
<em>{formatUsd(link.amountUsd)}</em>
</div>
))}
</div>
</article>
<article className="money-panel money-focus-panel money-focus-panel--wide">
<div className="money-panel-head">
<div>
<h2>经营信号</h2>
<span>D0</span>
</div>
<strong>{formatNumber(summary.newUsers)}</strong>
</div>
<div className="money-signal-grid">
<Signal label="D目标" value={formatPercent(summary.dayProgress)} tone={summary.dayProgress >= 100 ? "success" : "warning"} />
<Signal label="新增质量" value={`次留 ${formatPercent(summary.retention1)}`} tone="info" />
<Signal label="活跃规模" value={`DAU ${formatNumber(summary.dau)}`} tone="neutral" />
</div>
</article>
</section>
</>
);
}
function Signal({ label, tone, value }) {
return (
<div className={`money-signal money-signal--${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}

View File

@ -0,0 +1,101 @@
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
import DashboardCustomizeOutlined from "@mui/icons-material/DashboardCustomizeOutlined";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import TableRowsOutlined from "@mui/icons-material/TableRowsOutlined";
import { formatPercent, formatUsd } from "../format.js";
export const MONEY_VIEWS = [
{
id: "overview",
label: "财务总览",
title: "财务总览",
Icon: DashboardCustomizeOutlined
},
{
id: "modules",
label: "区域绩效",
title: "区域绩效",
Icon: TableRowsOutlined
},
{
id: "operators",
label: "运营绩效",
title: "运营绩效",
Icon: GroupsOutlined
},
{
id: "payments",
label: "支付链接",
title: "支付链接",
Icon: AddLinkOutlined
},
{
id: "countries",
label: "国家渠道",
title: "国家渠道",
Icon: PublicOutlined
}
];
export function MoneySidebar({ activeOperator, activeView, mode, onModeChange, onViewChange, session, summary }) {
return (
<aside className="money-sidebar">
<div className="money-sidebar-brand">
<div className="money-sidebar-mark">
<AccountBalanceWalletOutlined fontSize="small" />
</div>
<div>
<strong>Money OS</strong>
</div>
</div>
<nav className="money-sidebar-nav" aria-label="财务系统菜单">
{MONEY_VIEWS.map(({ Icon, id, label }) => (
<button
aria-current={activeView === id ? "page" : undefined}
className={activeView === id ? "is-active" : ""}
key={id}
onClick={() => onViewChange(id)}
type="button"
>
<Icon fontSize="small" />
<span>
<strong>{label}</strong>
</span>
</button>
))}
</nav>
<section className="money-sidebar-card">
<span>MODE</span>
<div className="money-mode-switch" role="tablist" aria-label="财务视角">
<button className={mode === "finance" ? "is-active" : ""} onClick={() => onModeChange("finance")} role="tab" type="button">
FIN
</button>
<button className={mode === "operator" ? "is-active" : ""} onClick={() => onModeChange("operator")} role="tab" type="button">
OPS
</button>
</div>
<strong>{mode === "operator" ? activeOperator.label : "ALL OPS"}</strong>
<small>{mode === "operator" ? activeOperator.department : "FINANCE"}</small>
</section>
<section className="money-sidebar-metric">
<PaymentsOutlined fontSize="small" />
<div>
<span>D0 充值</span>
<strong>{formatUsd(summary.rechargeUsd)}</strong>
<small>CVR {formatPercent(summary.linkConversion)}</small>
</div>
</section>
<footer className="money-sidebar-user">
<span>{session?.user?.username || "admin"}</span>
<small>money:view</small>
</footer>
</aside>
);
}

View File

@ -0,0 +1,101 @@
import { EChart } from "../charts/EChart.jsx";
import { trendLabels } from "../data.js";
import { formatPercent, formatUsd } from "../format.js";
export function MoneyTrendPanel({ summary }) {
return (
<section className="money-analysis">
<article className="money-panel money-panel--chart">
<PanelHead label="充值与流水趋势" meta="近 7 日" value={formatUsd(summary.revenueUsd)} />
<EChart className="money-trend-chart" option={createTrendOption(summary)} />
</article>
<article className="money-panel money-panel--targets">
<PanelHead label="目标达成" meta="当日 / 当月" value={formatPercent(summary.monthProgress)} />
<ProgressRow label="当日充值" percent={summary.dayProgress} value={`${formatUsd(summary.rechargeUsd)} / ${formatUsd(summary.dayTargetUsd)}`} />
<ProgressRow label="当月充值" percent={summary.monthProgress} value={`${formatUsd(summary.monthActualUsd)} / ${formatUsd(summary.monthTargetUsd)}`} />
<div className="money-retention">
<span>次留 {formatPercent(summary.retention1)}</span>
<span>7 {formatPercent(summary.retention7)}</span>
<span>30 {formatPercent(summary.retention30)}</span>
</div>
</article>
</section>
);
}
export function PanelHead({ label, meta, value }) {
return (
<div className="money-panel-head">
<div>
<h2>{label}</h2>
<span>{meta}</span>
</div>
<strong>{value}</strong>
</div>
);
}
function ProgressRow({ label, percent, value }) {
return (
<div className="money-progress">
<div>
<span>{label}</span>
<strong>{formatPercent(percent)}</strong>
</div>
<i>
<b style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
</i>
<small>{value}</small>
</div>
);
}
function createTrendOption(summary) {
return {
animationDuration: 520,
color: ["#2563eb", "#16a34a"],
grid: { bottom: 30, containLabel: true, left: 6, right: 16, top: 24 },
legend: {
bottom: 0,
icon: "roundRect",
itemHeight: 8,
itemWidth: 16,
textStyle: { color: "#64748b" }
},
tooltip: {
backgroundColor: "#ffffff",
borderColor: "#d8e0ec",
trigger: "axis",
textStyle: { color: "#0f172a" },
valueFormatter: (value) => formatUsd(value)
},
xAxis: {
axisLine: { lineStyle: { color: "rgba(100, 116, 139, 0.18)" } },
axisTick: { show: false },
data: trendLabels,
type: "category"
},
yAxis: {
axisLabel: { formatter: (value) => `${Math.round(value / 1000)}k` },
splitLine: { lineStyle: { color: "rgba(100, 116, 139, 0.16)" } },
type: "value"
},
series: [
{
areaStyle: { opacity: 0.12 },
data: summary.rechargeTrend,
name: "充值",
smooth: true,
symbol: "circle",
symbolSize: 6,
type: "line"
},
{
barMaxWidth: 18,
data: summary.revenueTrend,
name: "流水",
type: "bar"
}
]
};
}

View File

@ -0,0 +1,104 @@
import { summarizeMoneyModules } from "../data.js";
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function OperatorPerformancePanel({ modules }) {
const rows = moneyOperatorsFromModules(modules)
.filter((operator) => operator.modules.length > 0)
.sort((a, b) => b.summary.rechargeUsd - a.summary.rechargeUsd);
return (
<section className="money-operator-board">
<div className="money-operator-cards">
{rows.map((operator, index) => (
<article className="money-panel money-operator-card" key={operator.id}>
<div className="money-rank">{String(index + 1).padStart(2, "0")}</div>
<span>{operator.department}</span>
<h2>{operator.label}</h2>
<strong>{formatUsd(operator.summary.rechargeUsd)}</strong>
<div className="money-operator-meta">
<small>{operator.modules.length} 个区域</small>
<small>M {formatPercent(operator.summary.monthProgress)}</small>
</div>
</article>
))}
</div>
<article className="money-panel money-table-panel">
<div className="money-table-head">
<div>
<h2>运营绩效排行</h2>
<span>{rows.length} operators</span>
</div>
<strong>{rows.length} </strong>
</div>
<div className="money-table-scroll">
<table className="money-table money-table--compact">
<thead>
<tr>
<th>运营</th>
<th>负责区域</th>
<th>充值</th>
<th>流水</th>
<th>存量工资</th>
<th>毛利</th>
<th>新增 / 日活</th>
<th>日目标</th>
<th>月目标</th>
<th>链接转化</th>
</tr>
</thead>
<tbody>
{rows.map((operator) => (
<tr key={operator.id}>
<td>
<Stack primary={operator.label} secondary={operator.department} />
</td>
<td>{operator.modules.length}</td>
<td className="money-num money-num--success">{formatUsd(operator.summary.rechargeUsd)}</td>
<td className="money-num">{formatUsd(operator.summary.revenueUsd)}</td>
<td className="money-num money-num--warning">{formatUsd(operator.summary.salaryUsd)}</td>
<td className="money-num money-num--primary">{formatUsd(operator.summary.grossProfitUsd)}</td>
<td>
<Stack primary={formatNumber(operator.summary.newUsers)} secondary={`DAU ${formatNumber(operator.summary.dau)}`} />
</td>
<td>{formatPercent(operator.summary.dayProgress)}</td>
<td>{formatPercent(operator.summary.monthProgress)}</td>
<td>{formatPercent(operator.summary.linkConversion)}</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
</section>
);
}
function moneyOperatorsFromModules(modules) {
const byOperator = new Map();
modules.forEach((module) => {
const id = module.operatorId || "0";
if (!byOperator.has(id)) {
byOperator.set(id, {
department: module.department || "",
id,
label: module.operatorName || id,
modules: []
});
}
byOperator.get(id).modules.push(module);
});
return [...byOperator.values()].map((operator) => ({
...operator,
summary: summarizeMoneyModules(operator.modules)
}));
}
function Stack({ primary, secondary }) {
return (
<span className="money-stack">
<strong>{primary || "-"}</strong>
<small>{secondary || "-"}</small>
</span>
);
}

View File

@ -0,0 +1,560 @@
import { useEffect, useMemo, useState } from "react";
import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import { formatDateTime, formatUsd } from "../format.js";
import {
createAdminTemporaryPaymentLink,
listAdminPaymentMethods,
listAdminTemporaryPaymentLinks
} from "../paymentLinksApi.js";
import {
buildTemporaryCountries,
defaultReturnUrl,
dollarsToMinor,
temporaryCountryCode,
temporaryCountryLabel,
temporaryLogoText,
temporaryMethodLabel
} from "../temporaryPaymentApi.js";
import { PanelHead } from "./MoneyTrendPanel.jsx";
const statusOptions = [
["", "全部状态"],
["pending", "待支付"],
["redirected", "已生成"],
["paid", "已支付"],
["failed", "失败"],
["credited", "已入账"]
];
export function PaymentLinkPanel({ links = [], modules = [], onCopy, onCreated }) {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [query, setQuery] = useState({ keyword: "", status: "" });
const [state, setState] = useState({ error: "", items: [], loading: true });
const [dialogOpen, setDialogOpen] = useState(false);
const [methods, setMethods] = useState([]);
const [methodsState, setMethodsState] = useState({ error: "", loading: false });
const [createState, setCreateState] = useState({ error: "", loading: false });
const [form, setForm] = useState(() => ({
appCode: "",
countryCode: "",
methodId: "",
moduleId: "",
usdAmount: "99"
}));
const appOptions = useMemo(() => appOptionsFromModules(modules), [modules]);
const appCodes = useMemo(() => appOptions.map(([appCode]) => appCode), [appOptions]);
const appCodesKey = appCodes.join("|");
useEffect(() => {
setForm((current) => {
if (current.appCode && appCodes.includes(current.appCode)) {
return current;
}
return {
...current,
appCode: appCodes[0] || "",
countryCode: "",
methodId: "",
moduleId: ""
};
});
}, [appCodes, appCodesKey]);
useEffect(() => {
let mounted = true;
if (!appCodes.length) {
setState({ error: "", items: [], loading: false });
return () => {
mounted = false;
};
}
setState((current) => ({ ...current, error: "", loading: true }));
Promise.all(appCodes.map((appCode) => listAdminTemporaryPaymentLinks({ ...query, appCode })))
.then((pages) => {
if (mounted) {
setState({ error: "", items: mergeLinks(pages.flatMap((page) => normalizeLinks(page, modules))), loading: false });
}
})
.catch((err) => {
if (mounted) {
setState({ error: err.message || "加载支付链接失败", items: [], loading: false });
}
});
return () => {
mounted = false;
};
}, [appCodes, appCodesKey, modules, query]);
const countries = useMemo(() => buildTemporaryCountries(methods), [methods]);
const methodsForCountry = useMemo(
() =>
methods
.filter((method) => temporaryCountryCode(method) === form.countryCode)
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0) || temporaryMethodLabel(a).localeCompare(temporaryMethodLabel(b))),
[form.countryCode, methods]
);
const regionOptions = useMemo(() => regionsForCountry(modules, form.appCode, form.countryCode), [form.appCode, form.countryCode, modules]);
const selectedMethod = methodsForCountry.find((method) => String(method.method_id) === String(form.methodId));
const selectedRegion = regionOptions.find((option) => option.value === form.moduleId);
const selectedModule = selectedRegion?.module;
const rows = useMemo(() => mergeLinks([...state.items, ...links.map((link) => normalizeLegacyLink(link, modules))]), [links, modules, state.items]);
const canCreate = Boolean(form.appCode && form.countryCode && form.moduleId && selectedMethod && selectedModule && dollarsToMinor(form.usdAmount) > 0 && !createState.loading);
useEffect(() => {
if (!dialogOpen || !form.appCode) {
setMethods([]);
setMethodsState({ error: "", loading: false });
return;
}
let mounted = true;
setMethodsState({ error: "", loading: true });
listAdminPaymentMethods(form.appCode)
.then((nextMethods) => {
if (mounted) {
const activeMethods = nextMethods.filter((method) => !method.status || method.status === "active");
setMethods(activeMethods);
setMethodsState({ error: activeMethods.length ? "" : "当前无数据", loading: false });
}
})
.catch((err) => {
if (mounted) {
setMethods([]);
setMethodsState({ error: err.message || "支付方式加载失败", loading: false });
}
});
return () => {
mounted = false;
};
}, [dialogOpen, form.appCode]);
useEffect(() => {
if (!countries.length) {
setForm((current) => (current.countryCode ? { ...current, countryCode: "", methodId: "", moduleId: "" } : current));
return;
}
setForm((current) => (countries.some((country) => country.code === current.countryCode) ? current : { ...current, countryCode: countries[0].code, methodId: "", moduleId: "" }));
}, [countries]);
useEffect(() => {
if (!methodsForCountry.length) {
setForm((current) => (current.methodId ? { ...current, methodId: "" } : current));
return;
}
setForm((current) =>
methodsForCountry.some((method) => String(method.method_id) === String(current.methodId))
? current
: { ...current, methodId: String(methodsForCountry[0].method_id) }
);
}, [methodsForCountry]);
useEffect(() => {
if (!regionOptions.length) {
setForm((current) => (current.moduleId ? { ...current, moduleId: "" } : current));
return;
}
setForm((current) => (regionOptions.some((option) => option.value === current.moduleId) ? current : { ...current, moduleId: regionOptions[0].value }));
}, [regionOptions]);
const submit = (event) => {
event.preventDefault();
setQuery({ keyword: keyword.trim(), status });
};
const updateForm = (key) => (value) => {
setForm((current) => {
const next = { ...current, [key]: value };
if (key === "appCode") {
return { ...next, countryCode: "", methodId: "", moduleId: "" };
}
if (key === "countryCode") {
return { ...next, methodId: "", moduleId: "" };
}
return next;
});
};
const submitCreate = async (event) => {
event.preventDefault();
const usdMinorAmount = dollarsToMinor(form.usdAmount);
if (!canCreate || !selectedMethod || !selectedModule || usdMinorAmount <= 0) {
setCreateState({ error: "参数不完整", loading: false });
return;
}
setCreateState({ error: "", loading: true });
try {
const data = await createAdminTemporaryPaymentLink({
appCode: form.appCode,
paymentMethodId: Number(selectedMethod.method_id),
providerCode: selectedMethod.provider_code,
regionId: Number(selectedModule.regionId),
returnUrl: defaultReturnUrl(form.appCode),
usdMinorAmount
});
const link = normalizeCreatedLink(data, {
method: selectedMethod,
module: selectedModule
});
setState((current) => ({ ...current, items: mergeLinks([link, ...current.items]) }));
onCreated?.(link);
setDialogOpen(false);
setCreateState({ error: "", loading: false });
setQuery({ keyword: keyword.trim(), status });
} catch (err) {
setCreateState({ error: err.message || "创建失败", loading: false });
}
};
return (
<section className="money-panel money-link-panel">
<PanelHead
label="支付链接"
meta={state.error || `${modules.length} 个区域`}
value={state.loading ? "..." : String(rows.length)}
/>
<form className="money-link-form" onSubmit={submit}>
<label className="money-field money-filter--search">
<span>搜索</span>
<input placeholder="订单 / 用户 / 国家" value={keyword} onChange={(event) => setKeyword(event.target.value)} />
</label>
<Select label="状态" value={status} onChange={setStatus} options={statusOptions} />
<button className="money-button" disabled={state.loading} type="submit">
<SearchOutlined fontSize="small" />
查询
</button>
<button className="money-button" disabled={state.loading} onClick={() => setQuery({ keyword: keyword.trim(), status })} type="button">
<RefreshOutlined fontSize="small" />
刷新
</button>
<button className="money-button money-button--primary" disabled={!appOptions.length} onClick={() => setDialogOpen(true)} type="button">
<AddLinkOutlined fontSize="small" />
创建支付链接
</button>
</form>
<div className="money-link-list">
{rows.map((link) => (
<article className="money-link-row" key={link.id}>
<div>
<strong>{link.moduleName}</strong>
<span>{`${link.countryName} / ${link.providerName} / ${formatUsd(link.amountUsd)}`}</span>
<code title={link.payUrl}>{link.payUrl || "-"}</code>
</div>
<aside>
<span className={`money-status ${link.status === "paid" || link.status === "credited" ? "money-status--success" : ""}`}>
{paymentStatusLabel(link.status)}
</span>
<small>{formatDateTime(link.createdAtMs)}</small>
</aside>
<nav>
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => onCopy(link.payUrl)} type="button" aria-label="复制链接">
<ContentCopyOutlined fontSize="small" />
</button>
<button className="money-icon-button" disabled={!link.payUrl} onClick={() => openLink(link.payUrl)} type="button" aria-label="打开链接">
<OpenInNewOutlined fontSize="small" />
</button>
</nav>
</article>
))}
{!rows.length ? <div className="money-payment-empty">当前无数据</div> : null}
</div>
{dialogOpen ? (
<CreatePaymentDialog
appOptions={appOptions}
canCreate={canCreate}
countries={countries}
createState={createState}
form={form}
methodsForCountry={methodsForCountry}
methodsState={methodsState}
onClose={() => setDialogOpen(false)}
onSubmit={submitCreate}
onUpdate={updateForm}
regionOptions={regionOptions}
/>
) : null}
</section>
);
}
function CreatePaymentDialog({
appOptions,
canCreate,
countries,
createState,
form,
methodsForCountry,
methodsState,
onClose,
onSubmit,
onUpdate,
regionOptions
}) {
return (
<div className="money-modal-backdrop" role="presentation">
<section className="money-payment-dialog" role="dialog" aria-modal="true" aria-label="创建支付链接">
<header>
<div>
<h2>创建支付链接</h2>
<span>{methodsState.loading ? "加载支付方式" : `${countries.length} 个国家`}</span>
</div>
<button className="money-icon-button" onClick={onClose} type="button" aria-label="关闭">
<CloseOutlined fontSize="small" />
</button>
</header>
<form className="money-create-form" onSubmit={onSubmit}>
<div className="money-create-grid">
<Select label="App" value={form.appCode} onChange={onUpdate("appCode")} options={appOptions} />
<Select label="国家" value={form.countryCode} onChange={onUpdate("countryCode")} options={countries.map((country) => [country.code, temporaryCountryLabel(country)])} />
<Select label="区域" value={form.moduleId} onChange={onUpdate("moduleId")} options={regionOptions.map((option) => [option.value, option.label])} />
<label className="money-field">
<span>金额 USD</span>
<input inputMode="decimal" placeholder="99.00" value={form.usdAmount} onChange={(event) => onUpdate("usdAmount")(event.target.value)} />
</label>
</div>
<section className="money-method-select">
<div className="money-method-select-head">
<strong>支付方式</strong>
<span>{methodsForCountry.length}</span>
</div>
<div className="money-method-options money-method-options--dialog">
{methodsForCountry.length ? (
methodsForCountry.map((method) => (
<MethodButton
active={String(method.method_id) === String(form.methodId)}
key={method.method_id}
method={method}
onClick={() => onUpdate("methodId")(String(method.method_id))}
/>
))
) : (
<div className="money-payment-empty">{methodsState.loading ? "加载中" : methodsState.error || "当前无数据"}</div>
)}
</div>
</section>
<footer>
{createState.error ? <span className="money-payment-message money-payment-message--error">{createState.error}</span> : <span />}
<button className="money-button money-button--primary" disabled={!canCreate} type="submit">
<AddLinkOutlined fontSize="small" />
{createState.loading ? "创建中" : "创建链接"}
</button>
</footer>
</form>
</section>
</div>
);
}
function Select({ label, onChange, options, value }) {
return (
<label className="money-field">
<span>{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.length ? options.map(([optionValue, optionLabel]) => (
<option key={optionValue || "all"} value={optionValue}>
{optionLabel}
</option>
)) : <option value="">当前无数据</option>}
</select>
</label>
);
}
function MethodButton({ active, method, onClick }) {
return (
<button className={active ? "money-method-option is-active" : "money-method-option"} onClick={onClick} type="button">
<span className="money-method-logo">{method.logo_url ? <img alt={method.method_name || method.pay_type || "pay"} src={method.logo_url} /> : temporaryLogoText(method)}</span>
<span className="money-method-copy">
<strong>{method.method_name || method.pay_type || method.pay_way || "-"}</strong>
<small>{[method.provider_name || method.provider_code, method.pay_way, method.pay_type].filter(Boolean).join(" / ")}</small>
</span>
<span className="money-method-tags">
<b>{method.currency_code || "-"}</b>
<em>ID {method.method_id}</em>
</span>
</button>
);
}
function normalizeLinks(data, modules) {
const items = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
return items.map((item) => normalizeAdminLink(item, modules)).filter(Boolean);
}
function normalizeAdminLink(item, modules = []) {
const order = item.order || item;
const id = stringValue(order.orderId ?? order.order_id ?? order.id);
if (!id) {
return null;
}
const appCode = stringValue(order.appCode ?? order.app_code);
const regionId = numberValue(order.regionId ?? order.region_id);
const ownerUserId = stringValue(order.ownerUserId ?? order.owner_user_id);
const module = moduleForLink(modules, appCode, regionId, ownerUserId);
const usdMinorAmount = order.usdMinorAmount ?? order.usd_minor_amount;
return {
amountUsd: usdMinorAmount === 0 || usdMinorAmount ? Number(usdMinorAmount) / 100 : Number(order.amountUsd ?? order.amount_usd ?? 0),
appCode,
countryCode: stringValue(order.countryCode ?? order.country_code),
countryName: stringValue(order.countryName ?? order.country_name ?? order.countryCode ?? order.country_code) || "-",
createdAtMs: order.createdAtMs ?? order.created_at_ms ?? order.createdAt ?? order.created_at,
id,
moduleId: module?.id || scopeKey(appCode, regionId),
moduleName: module?.moduleName || [appCode, regionId ? `区域 ${regionId}` : ""].filter(Boolean).join(" / ") || "Temporary Pay",
ownerUserId,
payUrl: stringValue(item.paymentLink ?? item.payment_link ?? order.payUrl ?? order.pay_url),
providerName: stringValue(order.providerName ?? order.provider_name ?? order.providerCode ?? order.provider_code) || "-",
region: module?.region || (regionId ? String(regionId) : ""),
regionId,
status: stringValue(order.status)
};
}
function normalizeLegacyLink(link, modules) {
if (!link) {
return null;
}
const module = moduleForLink(modules, link.appCode, link.regionId, link.ownerUserId) || modules.find((item) => item.id === link.moduleId);
return {
amountUsd: Number(link.amountUsd || 0),
appCode: link.appCode || module?.appCode || "",
countryCode: link.countryCode,
countryName: link.countryName || link.countryCode || "-",
createdAtMs: link.createdAtMs,
id: link.id,
moduleId: link.moduleId || module?.id || "",
moduleName: link.moduleName || module?.moduleName || "-",
ownerUserId: link.ownerUserId,
payUrl: link.payUrl || "",
providerName: link.providerName || "-",
region: link.region || module?.region || "",
regionId: link.regionId || module?.regionId || 0,
status: link.status || ""
};
}
function normalizeCreatedLink(data, { method, module }) {
return normalizeAdminLink(data, [module]) || {
amountUsd: dollarsToMinor("0") / 100,
appCode: module.appCode,
countryCode: temporaryCountryCode(method),
countryName: method.country_name || temporaryCountryCode(method),
createdAtMs: Date.now(),
id: `${module.appCode}:${module.regionId}:${Date.now()}`,
moduleId: module.id,
moduleName: module.moduleName,
payUrl: "",
providerName: stringValue(method.provider_name ?? method.provider_code) || "-",
region: module.region,
regionId: module.regionId,
status: "redirected"
};
}
function regionsForCountry(modules, appCode, countryCode) {
const normalizedAppCode = stringValue(appCode);
const normalizedCountryCode = stringValue(countryCode).toUpperCase();
const byScope = new Map();
modules.forEach((module) => {
if (module.appCode !== normalizedAppCode || Number(module.regionId) <= 0 || !moduleCoversCountry(module, normalizedCountryCode)) {
return;
}
const key = scopeKey(module.appCode, module.regionId);
if (!byScope.has(key)) {
byScope.set(key, {
label: `${module.moduleName} · ${module.region}`,
module,
value: key
});
}
});
return [...byScope.values()].sort((a, b) => a.label.localeCompare(b.label, "zh-Hans-CN"));
}
function moduleCoversCountry(module, countryCode) {
if (!countryCode) {
return true;
}
const codes = Array.isArray(module.countryCodes) ? module.countryCodes : [];
return module.countryCode === "ALL" || module.countryCode === countryCode || codes.includes(countryCode);
}
function moduleForLink(modules, appCode, regionId, ownerUserId) {
const normalizedOwner = stringValue(ownerUserId);
return (
modules.find(
(module) =>
module.appCode === appCode &&
Number(module.regionId) === Number(regionId) &&
normalizedOwner &&
String(module.operatorId) === normalizedOwner
) ||
modules.find((module) => module.appCode === appCode && Number(module.regionId) === Number(regionId)) ||
null
);
}
function appOptionsFromModules(modules) {
const byCode = new Map();
modules.forEach((module) => {
const appCode = stringValue(module.appCode);
if (appCode && !byCode.has(appCode)) {
byCode.set(appCode, module.appName || appCode);
}
});
return [...byCode.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), "zh-Hans-CN"));
}
function mergeLinks(items) {
const byId = new Map();
items.filter(Boolean).forEach((item) => {
if (!item.id || byId.has(item.id)) {
return;
}
byId.set(item.id, item);
});
return [...byId.values()].sort((a, b) => Number(b.createdAtMs || 0) - Number(a.createdAtMs || 0));
}
function paymentStatusLabel(status) {
if (status === "paid") {
return "已支付";
}
if (status === "redirected") {
return "已生成";
}
if (status === "pending") {
return "待支付";
}
if (status === "failed") {
return "失败";
}
if (status === "credited") {
return "已入账";
}
return status || "-";
}
function scopeKey(appCode, regionId) {
return `${appCode}:${Number(regionId || 0)}`;
}
function numberValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function stringValue(value) {
return String(value ?? "").trim();
}
function openLink(value) {
if (value) {
window.open(value, "_blank", "noopener,noreferrer");
}
}

View File

@ -0,0 +1,136 @@
import { formatNumber, formatPercent, formatUsd } from "../format.js";
export function PerformanceTable({ meta = "", modules, summary, title = "区域绩效" }) {
const rows = [createTotalRow(summary), ...modules];
return (
<section className="money-panel money-table-panel">
<div className="money-table-head">
<div>
<h2>{title}</h2>
<span>{meta || `${modules.length} 个区域`}</span>
</div>
<strong>{modules.length}</strong>
</div>
<div className="money-table-scroll">
<table className="money-table">
<thead>
<tr>
<th>负责区域</th>
<th>负责人</th>
<th>区域类型</th>
<th>国家 / 地区</th>
<th>充值</th>
<th>App 流水</th>
<th>存量工资</th>
<th>毛利</th>
<th>新增 / 日活</th>
<th>当日目标</th>
<th>当月目标</th>
<th>留存</th>
<th>支付链接</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr className={row.isTotal ? "is-total" : ""} key={row.id}>
<td>
<Stack primary={row.moduleName} secondary={row.appName} />
</td>
<td>
<Stack primary={row.operatorName} secondary={row.department} />
</td>
<td>
<Scope scope={row.scope} />
</td>
<td>
<Stack primary={row.countryName} secondary={row.region} />
</td>
<td className="money-num money-num--success">{formatUsd(row.rechargeUsd)}</td>
<td className="money-num">{formatUsd(row.revenueUsd)}</td>
<td className="money-num money-num--warning">{formatUsd(row.salaryUsd)}</td>
<td className="money-num money-num--primary">{formatUsd(row.revenueUsd - row.salaryUsd)}</td>
<td>
<Stack primary={formatNumber(row.newUsers)} secondary={`DAU ${formatNumber(row.dau)}`} />
</td>
<td>
<MiniProgress actual={row.rechargeUsd} percent={row.dayProgress} target={row.dayTargetUsd} />
</td>
<td>
<MiniProgress actual={row.monthActualUsd} percent={row.monthProgress} target={row.monthTargetUsd} />
</td>
<td>
<Stack primary={`次留 ${formatPercent(row.retention1)}`} secondary={`7日 ${formatPercent(row.retention7)} / 30日 ${formatPercent(row.retention30)}`} />
</td>
<td>
<Stack primary={`${formatNumber(row.paidLinkCount)} / ${formatNumber(row.linkCount)}`} secondary={`金币 ${formatNumber(row.coinIssued)}`} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function Stack({ primary, secondary }) {
return (
<span className="money-stack">
<strong>{primary || "-"}</strong>
<small>{secondary || "-"}</small>
</span>
);
}
function Scope({ scope }) {
const label = scope === "app" ? "全站" : scope === "region" ? "区域" : "汇总";
return <span className={`money-scope money-scope--${scope}`}>{label}</span>;
}
function MiniProgress({ actual, percent, target }) {
if (!target) {
return <span className="money-empty-tag">无目标</span>;
}
return (
<span className="money-mini-progress">
<span>
<b>{formatUsd(actual)}</b>
<strong>{formatPercent(percent)}</strong>
</span>
<i>
<em style={{ width: `${Math.min(Number(percent) || 0, 100)}%` }} />
</i>
<small>{formatUsd(target)}</small>
</span>
);
}
function createTotalRow(summary) {
return {
appName: "全部 App",
coinIssued: summary.coinIssued,
countryName: "全部国家",
dau: summary.dau,
dayProgress: summary.dayProgress,
dayTargetUsd: summary.dayTargetUsd,
department: "财务汇总",
id: "__total",
isTotal: true,
linkCount: summary.totalLinkCount,
moduleName: "Total",
monthActualUsd: summary.monthActualUsd,
monthProgress: summary.monthProgress,
monthTargetUsd: summary.monthTargetUsd,
newUsers: summary.newUsers,
operatorName: "全部负责人",
paidLinkCount: summary.paidLinkCount,
rechargeUsd: summary.rechargeUsd,
region: "Global",
retention1: summary.retention1,
retention7: summary.retention7,
retention30: summary.retention30,
revenueUsd: summary.revenueUsd,
salaryUsd: summary.salaryUsd,
scope: "total"
};
}

16
money/src/data.js Normal file
View File

@ -0,0 +1,16 @@
export {
availableCountriesForModule,
availableProvidersForCountry,
createPaymentLinkRecord,
filterMoneyModules,
initialPaymentLinks,
moneyAppOptions,
moneyCountries,
moneyModuleMatchesRegion,
moneyModules,
moneyOperators,
moneyProviderOptions,
paymentStatusLabel,
summarizeMoneyModules,
trendLabels
} from "./model/data.js";

1
money/src/format.js Normal file
View File

@ -0,0 +1 @@
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";

10
money/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { MoneyApp } from "./MoneyApp.jsx";
import "./styles/index.css";
createRoot(document.getElementById("money-root")).render(
<React.StrictMode>
<MoneyApp />
</React.StrictMode>
);

306
money/src/masterDataApi.js Normal file
View File

@ -0,0 +1,306 @@
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { adminApiGet } from "./api.js";
export async function fetchMoneyMasterData() {
const [scopeResult, performanceResult] = await Promise.allSettled([fetchMoneyScope(), fetchMoneyPerformance()]);
const errors = [scopeResult, performanceResult]
.filter((result) => result.status === "rejected")
.map((result) => result.reason?.message || "主数据加载失败");
const scope = scopeResult.status === "fulfilled" ? scopeResult.value : emptyScope();
const performance = performanceResult.status === "fulfilled" ? performanceResult.value : { items: [], summary: {} };
const modules = buildMoneyModules(scope, performance.items || []);
return {
apps: scope.apps,
countries: scope.countries,
error: errors.join(""),
modules,
performanceSummary: performance.summary || {},
regions: scope.regions,
scopes: scope.scopes
};
}
export async function fetchMoneyScope() {
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyScope));
return {
all: Boolean(data?.all),
apps: normalizeAdminApps(data?.apps || []),
countries: normalizeCountries(data?.countries || []),
regions: normalizeRegions(data?.regions || []),
scopes: normalizeScopes(data?.scopes || [])
};
}
export async function fetchMoneyPerformance(query = {}) {
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.getMoneyPerformance), { query });
return {
items: Array.isArray(data?.items) ? data.items : [],
summary: data?.summary || {}
};
}
export async function listMoneyApps() {
return normalizeAdminApps(await adminApiGet(apiEndpointPath(API_OPERATIONS.listApps)));
}
export async function listMoneyCountries(query = {}) {
return normalizeCountries(await adminApiGet(apiEndpointPath(API_OPERATIONS.listCountries), { query }));
}
export async function listMoneyRegions(query = {}) {
return normalizeRegions(await adminApiGet(apiEndpointPath(API_OPERATIONS.listRegions), { query }));
}
export function normalizeAdminApps(data) {
return listItems(data)
.map((item) => {
const appCode = stringValue(item.appCode ?? item.app_code ?? item.code);
if (!appCode) {
return null;
}
return {
appCode,
appId: item.appId ?? item.app_id ?? item.id,
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode,
packageName: stringValue(item.packageName ?? item.package_name),
platform: stringValue(item.platform),
status: item.status
};
})
.filter(Boolean);
}
export function normalizeCountries(data) {
return listItems(data)
.map((item) => {
const countryCode = stringValue(item.countryCode ?? item.country_code ?? item.code).toUpperCase();
if (!countryCode) {
return null;
}
return {
countryCode,
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name ?? item.displayName ?? item.display_name),
countryId: item.countryId ?? item.country_id ?? item.id,
countryName: stringValue(item.countryName ?? item.country_name ?? item.name),
enabled: item.enabled,
flag: stringValue(item.flag),
appCode: stringValue(item.appCode ?? item.app_code),
isoAlpha3: stringValue(item.isoAlpha3 ?? item.iso_alpha3),
isoNumeric: stringValue(item.isoNumeric ?? item.iso_numeric),
phoneCountryCode: stringValue(item.phoneCountryCode ?? item.phone_country_code),
regionId: numberValue(item.regionId ?? item.region_id),
sortOrder: numberValue(item.sortOrder ?? item.sort_order)
};
})
.filter(Boolean)
.sort((a, b) => a.sortOrder - b.sortOrder || countryLabel(a).localeCompare(countryLabel(b), "zh-Hans-CN"));
}
export function normalizeRegions(data) {
return listItems(data)
.map((item) => {
const regionId = item.regionId ?? item.region_id ?? item.id;
const regionCode = stringValue(item.regionCode ?? item.region_code ?? item.code).toUpperCase();
if (!(regionId === 0 || regionId) || regionCode === "GLOBAL") {
return null;
}
return {
appCode: stringValue(item.appCode ?? item.app_code),
countries: normalizeCountryCodes(item.countries ?? item.countryCodes ?? item.country_codes),
name: stringValue(item.name ?? item.regionName ?? item.region_name) || regionCode || `区域 ${regionId}`,
regionCode,
regionId,
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
status: item.status
};
})
.filter(Boolean)
.sort((a, b) => a.sortOrder - b.sortOrder || String(a.name).localeCompare(String(b.name), "zh-Hans-CN"));
}
export function appOptionsFromMasterData(apps, fallbackOptions) {
const options = uniqueOptions(apps.map((app) => [app.appCode, app.appName || app.appCode]));
return options.length ? [["", "全部 App"], ...options] : fallbackOptions;
}
export function countryOptionsFromMasterData(countries, fallbackCountries) {
const options = uniqueOptions(countries.map((country) => [country.countryCode, countryLabel(country)]));
return options.length ? [["", "全部国家"], ...options] : [["", "全部国家"], ...fallbackCountries.map((country) => [country.code, country.label])];
}
export function regionOptionsFromMasterData(regions) {
return [
["", "全部区域"],
...uniqueOptions(
regions.map((region) => [
String(region.regionId),
[region.name, region.regionCode, region.regionId].filter(Boolean).join(" · ")
])
)
];
}
export function countryLabel(country) {
return [country.flag, country.countryDisplayName || country.countryName, country.countryCode].filter(Boolean).join(" · ");
}
export function buildMoneyModules(scope, performanceItems = []) {
const apps = scope?.apps || [];
const regions = scope?.regions || [];
const countries = scope?.countries || [];
const performanceByRegion = new Map();
performanceItems.forEach((item) => {
const key = `${stringValue(item.appCode ?? item.app_code)}:${Number(item.regionId ?? item.region_id ?? 0)}:${Number(item.operatorUserId ?? item.operator_user_id ?? 0)}`;
performanceByRegion.set(key, item);
});
const modules = [];
performanceByRegion.forEach((item) => {
modules.push(moduleFromPerformance(item, apps, regions, countries));
});
scope.scopes
?.filter((scopeItem) => Number(scopeItem.regionId) >= 0)
.forEach((scopeItem) => {
if (Number(scopeItem.regionId) === 0) {
const appRegions = regions.filter((region) => region.appCode === scopeItem.appCode);
if (appRegions.length) {
appRegions.forEach((region) => {
const hasRegionModule = modules.some(
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(region.regionId)
);
if (!hasRegionModule) {
modules.push(emptyModuleFromScope({ appCode: scopeItem.appCode, regionId: region.regionId }, apps, regions, countries));
}
});
return;
}
}
const hasModule = modules.some(
(module) => module.appCode === scopeItem.appCode && Number(module.regionId) === Number(scopeItem.regionId)
);
if (!hasModule) {
modules.push(emptyModuleFromScope(scopeItem, apps, regions, countries));
}
});
if (!modules.length && scope.all) {
regions.forEach((region) => modules.push(emptyModuleFromScope({ appCode: region.appCode, regionId: region.regionId }, apps, regions, countries)));
}
return modules.sort((a, b) => b.rechargeUsd - a.rechargeUsd || a.moduleName.localeCompare(b.moduleName, "zh-Hans-CN"));
}
function moduleFromPerformance(item, apps, regions, countries) {
const appCode = stringValue(item.appCode ?? item.app_code);
const regionId = Number(item.regionId ?? item.region_id ?? 0);
const app = apps.find((nextApp) => nextApp.appCode === appCode) || {};
const region = regions.find((nextRegion) => nextRegion.appCode === appCode && Number(nextRegion.regionId) === regionId) || {};
const regionCountries = countries.filter((country) => country.appCode === appCode && Number(country.regionId) === regionId);
const paidUsd = Number(item.paidUsdMinor ?? item.paid_usd_minor ?? 0) / 100;
const paidLinkCount = Number(item.paidLinkCount ?? item.paid_link_count ?? 0);
const operatorId = String(item.operatorUserId ?? item.operator_user_id ?? "0");
const operatorName = stringValue(item.operatorName ?? item.operator_name ?? item.operatorAccount ?? item.operator_account) || "未归属";
return {
appCode,
appName: app.appName || appCode,
coinIssued: 0,
countryCode: regionCountries[0]?.countryCode || "ALL",
countryCodes: regionCountries.map((country) => country.countryCode),
countryName: regionCountries.length ? regionCountries.map(countryLabel).join(" / ") : "全部国家",
dau: 0,
dayTargetUsd: 0,
department: stringValue(item.operatorAccount ?? item.operator_account),
id: `${appCode}:${regionId}:${operatorId}`,
linkCount: paidLinkCount,
moduleName: `${app.appName || appCode} / ${region.name || (regionId === 0 ? "全部区域" : `区域 ${regionId}`)}`,
monthActualUsd: paidUsd,
monthTargetUsd: 0,
newUsers: 0,
operatorId,
operatorName,
paidLinkCount,
paidUsers: paidLinkCount,
rechargeTrend: [0, 0, 0, 0, 0, 0, paidUsd],
rechargeUsd: paidUsd,
region: [region.name, region.regionCode].filter(Boolean).join(" · ") || (regionId === 0 ? "全部区域" : String(regionId)),
regionId,
retention1: 0,
retention7: 0,
retention30: 0,
revenueTrend: [0, 0, 0, 0, 0, 0, paidUsd],
revenueUsd: paidUsd,
salaryUsd: 0,
scope: regionId === 0 ? "app" : "region"
};
}
function emptyModuleFromScope(scopeItem, apps, regions, countries) {
return moduleFromPerformance(
{
appCode: scopeItem.appCode,
operatorName: "-",
paidLinkCount: 0,
paidUsdMinor: 0,
regionId: scopeItem.regionId
},
apps,
regions,
countries
);
}
function normalizeScopes(scopes) {
return (Array.isArray(scopes) ? scopes : [])
.map((scope) => ({
appCode: stringValue(scope.appCode ?? scope.app_code),
regionId: numberValue(scope.regionId ?? scope.region_id)
}))
.filter((scope) => scope.appCode && scope.regionId >= 0);
}
function emptyScope() {
return { all: false, apps: [], countries: [], regions: [], scopes: [] };
}
function listItems(data) {
if (Array.isArray(data)) {
return data;
}
if (Array.isArray(data?.items)) {
return data.items;
}
if (Array.isArray(data?.list)) {
return data.list;
}
return [];
}
function normalizeCountryCodes(value) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => stringValue(item).toUpperCase()).filter(Boolean))];
}
function uniqueOptions(options) {
const seen = new Set();
return options.filter(([value]) => {
const key = String(value || "");
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function stringValue(value) {
return String(value ?? "").trim();
}
function numberValue(value) {
const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : 0;
}

View File

@ -0,0 +1,112 @@
import { afterEach, expect, test, vi } from "vitest";
import {
countryOptionsFromMasterData,
fetchMoneyMasterData,
normalizeAdminApps,
normalizeCountries,
normalizeRegions,
regionOptionsFromMasterData
} from "./masterDataApi.js";
afterEach(() => {
window.localStorage.clear();
vi.unstubAllGlobals();
});
test("normalizes app country and region master data for money filters", () => {
expect(normalizeAdminApps({ items: [{ app_code: "lalu", name: "Lalu" }] })).toEqual([
{ appCode: "lalu", appId: undefined, appName: "Lalu", packageName: "", platform: "", status: undefined }
]);
expect(normalizeCountries({ items: [{ country_code: "sa", country_display_name: "沙特", sort_order: 2 }] })).toEqual([
{
appCode: "",
countryCode: "SA",
countryDisplayName: "沙特",
countryId: undefined,
countryName: "",
enabled: undefined,
flag: "",
isoAlpha3: "",
isoNumeric: "",
phoneCountryCode: "",
regionId: 0,
sortOrder: 2
}
]);
expect(normalizeRegions({ items: [{ countries: ["sa", "AE"], name: "中东", region_code: "me", region_id: 7 }] })).toEqual([
{
appCode: "",
countries: ["SA", "AE"],
name: "中东",
regionCode: "ME",
regionId: 7,
sortOrder: 0,
status: undefined
}
]);
});
test("builds select options from real money master data", () => {
const countries = normalizeCountries({ items: [{ countryCode: "BR", countryDisplayName: "巴西" }] });
const regions = normalizeRegions({ items: [{ countries: ["BR"], name: "拉美", regionCode: "LATAM", regionId: 3 }] });
expect(countryOptionsFromMasterData(countries, [])).toEqual([
["", "全部国家"],
["BR", "巴西 · BR"]
]);
expect(regionOptionsFromMasterData(regions)).toEqual([
["", "全部区域"],
["3", "拉美 · LATAM · 3"]
]);
});
test("fetches money master data from real admin API paths", async () => {
const calls = [];
window.localStorage.setItem("hyapp-admin.access-token", "token-1");
window.localStorage.setItem("hyapp-admin.app-code", "yumi");
vi.stubGlobal(
"fetch",
vi.fn(async (input, init) => {
calls.push({ init, url: String(input) });
const url = String(input);
if (url.includes("/v1/admin/money/scope")) {
return jsonResponse({
all: false,
apps: [{ appCode: "lalu", appName: "Lalu" }],
countries: [{ appCode: "lalu", countryCode: "SA", countryDisplayName: "沙特", regionId: 2 }],
regions: [{ appCode: "lalu", countries: ["SA"], name: "中东", regionCode: "ME", regionId: 2 }],
scopes: [{ appCode: "lalu", regionId: 2 }]
});
}
if (url.includes("/v1/admin/money/performance")) {
return jsonResponse({
items: [{ appCode: "lalu", operatorName: "Admin", operatorUserId: 1, paidLinkCount: 1, paidUsdMinor: 1299, regionId: 2 }],
summary: { paidLinkCount: 1, paidUsdMinor: 1299 }
});
}
return jsonResponse(null, 404);
})
);
const data = await fetchMoneyMasterData();
expect(data.error).toBe("");
expect(data.apps.map((app) => app.appCode)).toEqual(["lalu"]);
expect(data.countries.map((country) => country.countryCode)).toEqual(["SA"]);
expect(data.regions.map((region) => region.regionId)).toEqual([2]);
expect(data.modules).toHaveLength(1);
expect(data.modules[0].rechargeUsd).toBe(12.99);
expect(calls.map((call) => new URL(call.url).pathname).sort()).toEqual([
"/api/v1/admin/money/performance",
"/api/v1/admin/money/scope"
]);
expect(calls[0].init.headers.Authorization).toBe("Bearer token-1");
expect(calls[0].init.headers["X-App-Code"]).toBe("yumi");
});
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), {
headers: { "content-type": "application/json" },
status
});
}

424
money/src/model/data.js Normal file
View File

@ -0,0 +1,424 @@
export const moneyOperators = [
{ id: "mina-chen", label: "Mina Chen", department: "平台运营组" },
{ id: "sarah-ali", label: "Sarah Ali", department: "中东运营组" },
{ id: "leo-wang", label: "Leo Wang", department: "拉美拓展组" },
{ id: "diego-costa", label: "Diego Costa", department: "直播增长组" },
];
export const moneyCountries = [
{ code: "AE", currency: "AED", label: "阿联酋", region: "MENA" },
{ code: "SA", currency: "SAR", label: "沙特", region: "MENA" },
{ code: "BR", currency: "BRL", label: "巴西", region: "LATAM" },
{ code: "MX", currency: "MXN", label: "墨西哥", region: "LATAM" },
{ code: "ID", currency: "IDR", label: "印尼", region: "SEA" },
{ code: "TR", currency: "TRY", label: "土耳其", region: "EMEA" },
];
export const moneyAppOptions = [
["", "全部 App"],
["hy-live", "HY Live"],
["voice-party", "Voice Party"],
["social-pro", "Social Pro"],
["spark-chat", "Spark Chat"],
];
export const moneyProviderOptions = [
{ code: "mifapay", label: "MiFaPay", countries: ["AE", "SA", "TR"] },
{ code: "v5pay", label: "V5Pay", countries: ["BR", "MX", "ID"] },
{ code: "stripe", label: "Stripe", countries: ["AE", "SA", "BR", "MX", "ID", "TR"] },
];
export const trendLabels = ["06/21", "06/22", "06/23", "06/24", "06/25", "06/26", "06/27"];
export const moneyModules = [
{
id: "hy-live-app",
appCode: "hy-live",
appName: "HY Live",
moduleName: "HY Live 全站",
scope: "app",
countryCode: "ALL",
countryName: "全部国家",
region: "Global",
operatorId: "mina-chen",
operatorName: "Mina Chen",
department: "平台运营组",
rechargeUsd: 148420,
revenueUsd: 316800,
salaryUsd: 64100,
paidUsers: 7620,
newUsers: 42850,
dau: 960400,
dayTargetUsd: 150000,
monthActualUsd: 3410000,
monthTargetUsd: 3800000,
retention1: 44.8,
retention7: 21.4,
retention30: 9.2,
linkCount: 46,
paidLinkCount: 31,
coinIssued: 15180000,
rechargeTrend: [112000, 124500, 118900, 133400, 141200, 139800, 148420],
revenueTrend: [246000, 258400, 249900, 281200, 297800, 301200, 316800],
},
{
id: "voice-party-ksa",
appCode: "voice-party",
appName: "Voice Party",
moduleName: "Voice Party / 沙特",
scope: "region",
countryCode: "SA",
countryName: "沙特",
region: "MENA-KSA",
operatorId: "sarah-ali",
operatorName: "Sarah Ali",
department: "中东运营组",
rechargeUsd: 76500,
revenueUsd: 164200,
salaryUsd: 29800,
paidUsers: 3090,
newUsers: 12800,
dau: 286400,
dayTargetUsd: 72000,
monthActualUsd: 1820000,
monthTargetUsd: 1750000,
retention1: 47.5,
retention7: 24.1,
retention30: 10.8,
linkCount: 29,
paidLinkCount: 22,
coinIssued: 7820000,
rechargeTrend: [54800, 61200, 59400, 68000, 70400, 73900, 76500],
revenueTrend: [126000, 132100, 129600, 145800, 151200, 158000, 164200],
},
{
id: "voice-party-uae",
appCode: "voice-party",
appName: "Voice Party",
moduleName: "Voice Party / 阿联酋",
scope: "region",
countryCode: "AE",
countryName: "阿联酋",
region: "MENA-UAE",
operatorId: "sarah-ali",
operatorName: "Sarah Ali",
department: "中东运营组",
rechargeUsd: 43800,
revenueUsd: 88200,
salaryUsd: 17200,
paidUsers: 2020,
newUsers: 7100,
dau: 155300,
dayTargetUsd: 52000,
monthActualUsd: 930000,
monthTargetUsd: 1200000,
retention1: 41.6,
retention7: 18.7,
retention30: 7.9,
linkCount: 18,
paidLinkCount: 10,
coinIssued: 3980000,
rechargeTrend: [39100, 40200, 38800, 42100, 43700, 43000, 43800],
revenueTrend: [76900, 80200, 78800, 83500, 86100, 87300, 88200],
},
{
id: "social-pro-brazil",
appCode: "social-pro",
appName: "Social Pro",
moduleName: "Social Pro / 巴西",
scope: "region",
countryCode: "BR",
countryName: "巴西",
region: "LATAM-BR",
operatorId: "leo-wang",
operatorName: "Leo Wang",
department: "拉美拓展组",
rechargeUsd: 58200,
revenueUsd: 126300,
salaryUsd: 22100,
paidUsers: 4200,
newUsers: 21100,
dau: 418700,
dayTargetUsd: 50000,
monthActualUsd: 1260000,
monthTargetUsd: 1080000,
retention1: 39.2,
retention7: 16.5,
retention30: 6.8,
linkCount: 34,
paidLinkCount: 26,
coinIssued: 6200000,
rechargeTrend: [42100, 43800, 47200, 50100, 52700, 54100, 58200],
revenueTrend: [89200, 93400, 102200, 109800, 115600, 118500, 126300],
},
{
id: "social-pro-mexico",
appCode: "social-pro",
appName: "Social Pro",
moduleName: "Social Pro / 墨西哥",
scope: "region",
countryCode: "MX",
countryName: "墨西哥",
region: "LATAM-MX",
operatorId: "leo-wang",
operatorName: "Leo Wang",
department: "拉美拓展组",
rechargeUsd: 31600,
revenueUsd: 70600,
salaryUsd: 14300,
paidUsers: 2380,
newUsers: 9400,
dau: 180200,
dayTargetUsd: 36000,
monthActualUsd: 720000,
monthTargetUsd: 820000,
retention1: 36.4,
retention7: 14.8,
retention30: 5.9,
linkCount: 15,
paidLinkCount: 9,
coinIssued: 3320000,
rechargeTrend: [27600, 28100, 29400, 30200, 31800, 30500, 31600],
revenueTrend: [60400, 62100, 64100, 66600, 68900, 68200, 70600],
},
{
id: "spark-chat-indonesia",
appCode: "spark-chat",
appName: "Spark Chat",
moduleName: "Spark Chat / 印尼",
scope: "region",
countryCode: "ID",
countryName: "印尼",
region: "SEA-ID",
operatorId: "diego-costa",
operatorName: "Diego Costa",
department: "直播增长组",
rechargeUsd: 39800,
revenueUsd: 90200,
salaryUsd: 18600,
paidUsers: 3510,
newUsers: 18400,
dau: 322600,
dayTargetUsd: 42000,
monthActualUsd: 860000,
monthTargetUsd: 940000,
retention1: 43.1,
retention7: 19.3,
retention30: 8.1,
linkCount: 21,
paidLinkCount: 14,
coinIssued: 4460000,
rechargeTrend: [33800, 35600, 36100, 38200, 39100, 38800, 39800],
revenueTrend: [76400, 80100, 81200, 84900, 87600, 88400, 90200],
},
];
export const initialPaymentLinks = [
createPaymentLinkRecord({
amountUsd: 299,
countryCode: "SA",
moduleId: "voice-party-ksa",
now: new Date("2026-06-27T08:32:00+08:00"),
operatorName: "Sarah Ali",
providerCode: "mifapay",
status: "paid",
}),
createPaymentLinkRecord({
amountUsd: 129,
countryCode: "BR",
moduleId: "social-pro-brazil",
now: new Date("2026-06-27T10:18:00+08:00"),
operatorName: "Leo Wang",
providerCode: "v5pay",
status: "redirected",
}),
createPaymentLinkRecord({
amountUsd: 499,
countryCode: "AE",
moduleId: "voice-party-uae",
now: new Date("2026-06-27T11:04:00+08:00"),
operatorName: "Sarah Ali",
providerCode: "mifapay",
status: "redirected",
}),
];
export function filterMoneyModules({
activeOperatorId = "",
appCode = "",
countryCode = "",
mode = "finance",
modules = moneyModules,
operatorKeyword = "",
regionId = "",
regions = [],
} = {}) {
const normalizedKeyword = operatorKeyword.trim().toLowerCase();
return modules.filter((module) => {
const matchesRole = mode !== "operator" || module.operatorId === activeOperatorId;
const matchesApp = !appCode || module.appCode === appCode;
const matchesCountry =
!countryCode || module.countryCode === countryCode || (module.scope === "app" && module.countryCode === "ALL");
const matchesRegion = !regionId || moneyModuleMatchesRegion(module, regionId, regions);
const matchesOperator =
!normalizedKeyword ||
module.operatorName.toLowerCase().includes(normalizedKeyword) ||
module.department.toLowerCase().includes(normalizedKeyword);
return matchesRole && matchesApp && matchesCountry && matchesRegion && matchesOperator;
});
}
export function moneyModuleMatchesRegion(module, regionId, regions = []) {
if (module.scope === "app" && module.countryCode === "ALL") {
return true;
}
const normalizedRegionId = String(regionId || "");
if (!normalizedRegionId) {
return true;
}
if ((module.regionId === 0 || module.regionId) && String(module.regionId) === normalizedRegionId) {
return true;
}
const region = regions.find((item) => String(item.regionId ?? item.value ?? item.id) === normalizedRegionId);
if (!region) {
return false;
}
const countryCodes = new Set(
(region.countries || region.countryCodes || region.country_codes || [])
.map((countryCode) =>
String(countryCode || "")
.trim()
.toUpperCase(),
)
.filter(Boolean),
);
return countryCodes.has(String(module.countryCode || "").toUpperCase());
}
export function summarizeMoneyModules(modules = []) {
const rechargeUsd = sumBy(modules, "rechargeUsd");
const revenueUsd = sumBy(modules, "revenueUsd");
const salaryUsd = sumBy(modules, "salaryUsd");
const paidUsers = sumBy(modules, "paidUsers");
const newUsers = sumBy(modules, "newUsers");
const dau = sumBy(modules, "dau");
const dayTargetUsd = sumBy(modules, "dayTargetUsd");
const monthActualUsd = sumBy(modules, "monthActualUsd");
const monthTargetUsd = sumBy(modules, "monthTargetUsd");
return {
arppuUsd: paidUsers > 0 ? rechargeUsd / paidUsers : 0,
coinIssued: sumBy(modules, "coinIssued"),
dau,
dayProgress: percentage(rechargeUsd, dayTargetUsd),
dayTargetUsd,
grossProfitUsd: revenueUsd - salaryUsd,
linkConversion: percentage(sumBy(modules, "paidLinkCount"), sumBy(modules, "linkCount")),
monthActualUsd,
monthProgress: percentage(monthActualUsd, monthTargetUsd),
monthTargetUsd,
newUsers,
paidLinkCount: sumBy(modules, "paidLinkCount"),
paidUsers,
rechargeTrend: sumTrend(modules, "rechargeTrend"),
rechargeUsd,
retention1: averageBy(modules, "retention1"),
retention7: averageBy(modules, "retention7"),
retention30: averageBy(modules, "retention30"),
revenueTrend: sumTrend(modules, "revenueTrend"),
revenueUsd,
salaryUsd,
totalLinkCount: sumBy(modules, "linkCount"),
};
}
export function createPaymentLinkRecord({
amountUsd,
countryCode,
moduleId,
now = new Date(),
operatorName,
providerCode,
status = "redirected",
}) {
const module = moneyModules.find((item) => item.id === moduleId) || moneyModules[0];
const country = moneyCountries.find((item) => item.code === countryCode) || moneyCountries[0];
const provider = moneyProviderOptions.find((item) => item.code === providerCode) || moneyProviderOptions[0];
const createdAtMs = now.getTime();
const suffix = compactTimestamp(now);
const orderId = `MNY-${country.code}-${suffix}`;
return {
amountUsd: Number(amountUsd) || 0,
appName: module.appName,
countryCode: country.code,
countryName: country.label,
createdAtMs,
currency: country.currency,
id: orderId,
moduleId: module.id,
moduleName: module.moduleName,
operatorName: operatorName || module.operatorName,
payUrl: `https://pay.hyapp.example/${country.code.toLowerCase()}/${orderId.toLowerCase()}`,
providerCode: provider.code,
providerName: provider.label,
region: country.region,
status,
};
}
export function availableCountriesForModule(moduleId) {
const module = moneyModules.find((item) => item.id === moduleId);
if (!module || module.scope === "app" || module.countryCode === "ALL") {
return moneyCountries;
}
return moneyCountries.filter((country) => country.code === module.countryCode);
}
export function availableProvidersForCountry(countryCode) {
return moneyProviderOptions.filter((provider) => provider.countries.includes(countryCode));
}
export function paymentStatusLabel(status) {
if (status === "paid") {
return "已支付";
}
if (status === "redirected") {
return "已生成";
}
if (status === "failed") {
return "失败";
}
return status || "-";
}
function sumBy(items, key) {
return items.reduce((total, item) => total + Number(item[key] || 0), 0);
}
function averageBy(items, key) {
if (!items.length) {
return 0;
}
return sumBy(items, key) / items.length;
}
function sumTrend(items, key) {
return trendLabels.map((_, index) => items.reduce((total, item) => total + Number(item[key]?.[index] || 0), 0));
}
function percentage(value, total) {
if (!total) {
return 0;
}
return (Number(value) / Number(total)) * 100;
}
function compactTimestamp(date) {
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(
date.getMinutes(),
)}${pad(date.getSeconds())}`;
}

View File

@ -0,0 +1,56 @@
import { expect, test } from "vitest";
import {
availableProvidersForCountry,
createPaymentLinkRecord,
filterMoneyModules,
moneyModules,
summarizeMoneyModules,
} from "./data.js";
test("filters all modules for finance and owned modules for operators", () => {
expect(filterMoneyModules({ mode: "finance" })).toHaveLength(moneyModules.length);
expect(filterMoneyModules({ activeOperatorId: "sarah-ali", mode: "operator" }).map((module) => module.id)).toEqual([
"voice-party-ksa",
"voice-party-uae",
]);
});
test("keeps app-level modules visible when filtering by country", () => {
const modules = filterMoneyModules({ countryCode: "BR", mode: "finance" });
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "social-pro-brazil"]);
});
test("filters region modules from real region country mappings", () => {
const modules = filterMoneyModules({
mode: "finance",
regionId: "2",
regions: [{ countries: ["SA", "AE"], name: "中东", regionCode: "ME", regionId: 2 }],
});
expect(modules.map((module) => module.id)).toEqual(["hy-live-app", "voice-party-ksa", "voice-party-uae"]);
});
test("summarizes visible modules for KPI cards and progress bars", () => {
const summary = summarizeMoneyModules(filterMoneyModules({ activeOperatorId: "leo-wang", mode: "operator" }));
expect(summary.rechargeUsd).toBe(89800);
expect(summary.totalLinkCount).toBe(49);
expect(summary.monthProgress).toBeGreaterThan(100);
});
test("creates country payment link records with available provider metadata", () => {
const link = createPaymentLinkRecord({
amountUsd: 129,
countryCode: "BR",
moduleId: "social-pro-brazil",
now: new Date("2026-06-27T00:00:00Z"),
providerCode: "v5pay",
});
expect(link.id).toMatch(/^MNY-BR-/);
expect(link.countryName).toBe("巴西");
expect(link.providerName).toBe("V5Pay");
expect(link.payUrl).toContain("/br/");
expect(availableProvidersForCountry("BR").map((provider) => provider.code)).toContain("v5pay");
});

32
money/src/model/format.js Normal file
View File

@ -0,0 +1,32 @@
export function formatUsd(value, options = {}) {
const digits = options.compact ? 1 : 2;
return `US$ ${Number(value || 0).toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: options.minimumFractionDigits ?? 0,
})}`;
}
export function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
export function formatPercent(value, digits = 1) {
return `${Number(value || 0).toLocaleString("zh-CN", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
})}%`;
}
export function formatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
const pad = (part) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes(),
)}`;
}

View File

@ -0,0 +1,55 @@
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { adminApiGet, adminApiRequest } from "./api.js";
const PAGE_SIZE = 50;
export function listAdminTemporaryPaymentLinks({ appCode = "", keyword = "", operatorUserId = "", providerCode = "", regionId = "", status = "" } = {}) {
return adminApiGet(apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks), {
query: {
app_code: appCode,
keyword,
operator_user_id: operatorUserId,
page: 1,
page_size: PAGE_SIZE,
provider_code: providerCode,
region_id: regionId,
status
}
});
}
export function getAdminTemporaryPaymentLink(orderId) {
return adminApiGet(apiEndpointPath(API_OPERATIONS.getTemporaryPaymentLink, { order_id: orderId }));
}
export function createAdminTemporaryPaymentLink(payload) {
return adminApiRequest(apiEndpointPath(API_OPERATIONS.createTemporaryPaymentLink), {
body: payload,
method: "POST"
});
}
export async function listAdminPaymentMethods(appCode) {
const data = await adminApiGet(apiEndpointPath(API_OPERATIONS.listThirdPartyPaymentChannels), {
headers: appCode ? { "X-App-Code": appCode } : undefined
});
const channels = Array.isArray(data?.items) ? data.items : [];
return channels.flatMap((channel) =>
(Array.isArray(channel.methods) ? channel.methods : []).map((method) => ({
...method,
app_code: method.appCode ?? method.app_code ?? channel.appCode ?? channel.app_code,
country_code: method.countryCode ?? method.country_code,
country_name: method.countryName ?? method.country_name,
currency_code: method.currencyCode ?? method.currency_code,
logo_url: method.logoUrl ?? method.logo_url,
method_id: method.methodId ?? method.method_id,
method_name: method.methodName ?? method.method_name,
pay_type: method.payType ?? method.pay_type,
pay_way: method.payWay ?? method.pay_way,
provider_code: method.providerCode ?? method.provider_code ?? channel.providerCode ?? channel.provider_code,
provider_name: method.providerName ?? method.provider_name ?? channel.providerName ?? channel.provider_name,
sort_order: method.sortOrder ?? method.sort_order,
status: method.status
}))
);
}

1704
money/src/styles/index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
const RETURN_URL_BASE = "https://h5.global-interaction.com/recharge/index.html";
export const TEMPORARY_RETURN_APPS = ["lalu", "yumi", "aslan"];
export function defaultReturnUrl(appCode = "lalu") {
const url = new URL(RETURN_URL_BASE);
url.searchParams.set("app_code", String(appCode || "lalu").trim() || "lalu");
return url.toString();
}
export function isKnownDefaultReturnUrl(value) {
const text = String(value || "").trim();
return TEMPORARY_RETURN_APPS.some((appCode) => text === defaultReturnUrl(appCode));
}
export function dollarsToMinor(value) {
const text = String(value || "").trim();
if (!/^\d+(\.\d{1,2})?$/.test(text)) {
return 0;
}
return Math.round(Number(text) * 100);
}
export function extractOrderId(value) {
const text = String(value || "").trim();
if (!text) {
return "";
}
try {
const url = new URL(text, window.location.href);
const queryId = url.searchParams.get("order_id") || url.searchParams.get("orderId");
if (queryId) {
return queryId.trim();
}
const match = url.pathname.match(/temporary-pay\/([^/]+)\.html$/);
if (match) {
return decodeURIComponent(match[1]);
}
} catch {
// Plain order ids are valid input.
}
return text.replace(/^order_id=/, "").trim();
}
export function formatMinorUsd(value) {
if (!(value === 0 || value)) {
return "-";
}
return `USD ${(Number(value) / 100).toLocaleString("zh-CN", {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})}`;
}
export function buildTemporaryCountries(sourceMethods) {
const byCode = {};
sourceMethods.forEach((method, index) => {
const code = temporaryCountryCode(method);
if (!code) {
return;
}
if (!byCode[code]) {
byCode[code] = {
code,
count: 0,
currency: method.currency_code || "",
firstSort: Number(method.sort_order || 0) || index,
name: method.country_name || code
};
}
byCode[code].count += 1;
});
return Object.keys(byCode)
.map((code) => byCode[code])
.sort((a, b) => a.firstSort - b.firstSort);
}
export function temporaryCountryCode(method) {
return String(method?.country_code || "")
.trim()
.toUpperCase();
}
export function temporaryCountryLabel(country) {
if (!country) {
return "";
}
return country.name && country.name !== country.code ? `${country.name} · ${country.code}` : country.code;
}
export function temporaryMethodLabel(method) {
return [method?.method_name, method?.pay_way, method?.pay_type, method?.provider_name].filter(Boolean).join(" ");
}
export function temporaryLogoText(method) {
const value = method?.method_name || method?.pay_type || method?.provider_name || method?.provider_code || "Pay";
return String(value).replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase();
}
export function temporaryStatusLabel(status) {
if (status === "paid") {
return "已支付";
}
if (status === "redirected") {
return "已生成";
}
if (status === "pending") {
return "待支付";
}
if (status === "failed") {
return "失败";
}
if (status === "credited") {
return "已入账";
}
return status || "-";
}

View File

@ -0,0 +1,35 @@
import { expect, test } from "vitest";
import { buildTemporaryCountries, defaultReturnUrl, dollarsToMinor, extractOrderId, temporaryStatusLabel } from "./temporaryPaymentApi.js";
test("converts usd text to minor amount with strict two-decimal validation", () => {
expect(dollarsToMinor("300")).toBe(30000);
expect(dollarsToMinor("9.99")).toBe(999);
expect(dollarsToMinor("9.999")).toBe(0);
expect(dollarsToMinor("abc")).toBe(0);
});
test("extracts temporary order id from plain text, query, and wrapped html url", () => {
expect(extractOrderId("ORDER-1")).toBe("ORDER-1");
expect(extractOrderId("order_id=ORDER-2")).toBe("ORDER-2");
expect(extractOrderId("https://pay.example/pay?order_id=ORDER-3")).toBe("ORDER-3");
expect(extractOrderId("https://cos.example/temporary-pay/ORDER-4.html")).toBe("ORDER-4");
});
test("builds country rows from temporary payment methods by first sort order", () => {
const countries = buildTemporaryCountries([
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 2, sort_order: 20 },
{ country_code: "SA", country_name: "Saudi", currency_code: "SAR", method_id: 1, sort_order: 10 },
{ country_code: "BR", country_name: "Brazil", currency_code: "BRL", method_id: 3, sort_order: 30 }
]);
expect(countries).toEqual([
{ code: "SA", count: 1, currency: "SAR", firstSort: 10, name: "Saudi" },
{ code: "BR", count: 2, currency: "BRL", firstSort: 20, name: "Brazil" }
]);
});
test("keeps h5 return app separate from payment app code", () => {
expect(defaultReturnUrl("yumi")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=yumi");
expect(defaultReturnUrl("unknown")).toBe("https://h5.global-interaction.com/recharge/index.html?app_code=unknown");
expect(temporaryStatusLabel("credited")).toBe("已入账");
});

View File

@ -38,6 +38,8 @@ export const PERMISSIONS = {
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
moneyView: "money:view",
moneyPaymentLinkCreate: "money:payment-link:create",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
coinAdjustmentView: "coin-adjustment:view",

View File

@ -49,3 +49,121 @@
gap: 8px;
min-width: 0;
}
.badgeCover {
display: inline-flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.badgeCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.resourceField :global(.MuiInputBase-input:not(:disabled)) {
cursor: pointer;
}
.resourceDrawer.resourceDrawer {
width: min(640px, 100vw);
height: 100%;
}
.resourceDrawerBody {
flex: 1;
min-height: 0;
overflow: hidden;
}
.resourceGrid {
display: grid;
min-height: 0;
align-content: start;
align-items: start;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-2);
overflow-y: auto;
padding-right: var(--space-1);
}
.resourceCard {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
min-width: 0;
min-height: 78px;
align-items: center;
gap: var(--space-1) var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.resourceCard:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
}
.resourceCard:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.resourceCardSelected,
.resourceCardSelected:hover {
border-color: var(--primary);
background: var(--primary-surface-strong);
box-shadow: inset 0 0 0 1px var(--primary-border);
}
.resourceCard .badgeCover {
grid-row: 1 / 3;
}
.resourceName {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
min-height: 180px;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-weight: 650;
}
@media (max-width: 640px) {
.resourceGrid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.resourceCard {
transition: none;
}
}

View File

@ -96,6 +96,16 @@ export function updateAchievementDefinition(
).then(normalizeAchievement);
}
export function deleteAchievementDefinition(achievementId: EntityId): Promise<AchievementDefinitionDto> {
const endpoint = API_ENDPOINTS.deleteAchievementDefinition;
return apiRequest<unknown>(
apiEndpointPath(API_OPERATIONS.deleteAchievementDefinition, { achievement_id: achievementId }),
{
method: endpoint.method,
},
).then(normalizeAchievement);
}
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
return {
...page,

View File

@ -8,8 +8,8 @@ export const achievementStatusOptions = [
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
export const collectionTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "活动成就"],
["ordinary", "普通荣耀成就"],
["event", "活动成就"],
];
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
@ -17,8 +17,8 @@ export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionT
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
export const achievementTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "限时活动"],
["ordinary", "普通荣耀成就"],
["activity_limited", "活动成就"],
];
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups, listResources } from "@/features/resources/api";
import { listResources } from "@/features/resources/api";
import {
createAchievementDefinition,
deleteAchievementDefinition,
listAchievementDefinitions,
updateAchievementDefinition,
} from "@/features/achievements/api";
@ -14,35 +16,16 @@ import { achievementFormSchema } from "@/features/achievements/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
const groupPageQuery = { page: 1, page_size: 500, status: "active" };
const emptyCondition = (condition = {}) => ({
dimensionFilterJson: condition.dimensionFilterJson || "{}",
metricType: condition.metricType || "",
targetUnit: condition.targetUnit || "count",
targetValue: condition.targetValue === 0 || condition.targetValue ? String(condition.targetValue) : "",
});
const emptyForm = (achievement = {}) => ({
achievementType: achievement.achievementType || "ordinary",
autoPinPolicy: achievement.autoPinPolicy || "if_empty",
collectionId: achievement.collectionId || "",
collectionType: achievement.collectionType || "ordinary",
conditions: achievement.conditions?.length ? achievement.conditions.map(emptyCondition) : [emptyCondition()],
description: achievement.description || "",
displayConfigJson: achievement.displayConfigJson || "{}",
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
honorVisibility: achievement.honorVisibility ?? true,
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
sortOrder: valueOrDefault(achievement.sortOrder, 0),
status: achievement.status || "draft",
title: achievement.title || "",
});
export function useAchievementsPage() {
const abilities = useAchievementAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [collectionType, setCollectionType] = useState("");
const [collectionId, setCollectionId] = useState("");
@ -52,7 +35,6 @@ export function useAchievementsPage() {
const [form, setForm] = useState(() => emptyForm());
const [loadingAction, setLoadingAction] = useState("");
const [badgeResources, setBadgeResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const filters = useMemo(
() => ({
@ -77,12 +59,8 @@ export function useAchievementsPage() {
const loadOptions = useCallback(async () => {
try {
const [resourcePage, groupPage] = await Promise.all([
listResources(resourcePageQuery),
listResourceGroups(groupPageQuery),
]);
const resourcePage = await listResources(resourcePageQuery);
setBadgeResources(resourcePage.items || []);
setResourceGroups(groupPage.items || []);
} catch (err) {
showToast(err.message || "加载资源选项失败", "error");
}
@ -144,46 +122,25 @@ export function useAchievementsPage() {
});
};
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
const deleteAchievement = async (achievement) => {
if (!abilities.canUpdate || !achievement?.achievementId) {
return;
}
const nextStatus = nextEnabled ? "active" : "paused";
if (achievement.status === nextStatus) {
const ok = await confirm({
confirmText: "删除",
message: `${achievement.title || achievement.achievementId} 会从成就配置列表和 App 成就列表移除,用户历史徽章展示保留。`,
title: "删除成就",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
await updateAchievementDefinition(
achievement.achievementId,
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
);
await runAction(`delete-${achievement.achievementId}`, "成就已删除", async () => {
await deleteAchievementDefinition(achievement.achievementId);
await reload();
});
};
const addCondition = () => {
setForm((current) => ({ ...current, conditions: [...current.conditions, emptyCondition()] }));
};
const removeCondition = (index) => {
setForm((current) => ({
...current,
conditions:
current.conditions.length > 1
? current.conditions.filter((_, itemIndex) => itemIndex !== index)
: current.conditions,
}));
};
const updateCondition = (index, key, value) => {
setForm((current) => ({
...current,
conditions: current.conditions.map((condition, itemIndex) =>
itemIndex === index ? { ...condition, [key]: value } : condition,
),
}));
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
@ -199,7 +156,6 @@ export function useAchievementsPage() {
return {
abilities,
activeAction,
addCondition,
badgeResources,
changeCollectionId,
changeCollectionType,
@ -215,39 +171,33 @@ export function useAchievementsPage() {
openEdit,
page,
reload: reloadAll,
removeCondition,
resetFilters,
resourceGroups,
selectedAchievement,
setForm,
setPage,
deleteAchievement,
submitAchievement,
toggleAchievement,
updateCondition,
};
}
function buildAchievementPayload(form) {
const achievementType = form.achievementType;
const collectionType = achievementType === "activity_limited" ? "event" : "ordinary";
return {
achievement_type: form.achievementType,
auto_pin_policy: form.autoPinPolicy,
collection_id: String(form.collectionId || "").trim(),
collection_type: form.collectionType,
conditions: form.conditions.map((condition) => ({
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
metric_type: String(condition.metricType || "").trim(),
target_unit: String(condition.targetUnit || "").trim(),
target_value: Number(condition.targetValue || 0),
})),
achievement_type: achievementType,
auto_pin_policy: "none",
collection_id: collectionType === "ordinary" ? "ordinary" : "",
collection_type: collectionType,
conditions: [],
description: String(form.description || "").trim(),
display_config_json: normalizedJson(form.displayConfigJson),
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
honor_visibility: Boolean(form.honorVisibility),
display_config_json: "{}",
effective_from_ms: 0,
effective_to_ms: 0,
honor_visibility: true,
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
reward_resource_group_id: 0,
sort_order: 0,
status: "active",
title: String(form.title || "").trim(),
};
}
@ -259,36 +209,6 @@ function resetSetter(setter, setPage) {
};
}
function msToDatetimeLocal(value) {
if (!value) {
return "";
}
const date = new Date(Number(value));
if (Number.isNaN(date.getTime())) {
return "";
}
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function datetimeLocalToMs(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return 0;
}
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizedJson(value) {
const raw = String(value || "").trim();
return raw || "{}";
}
function optionalId(value) {
return value ? String(value) : "";
}
function valueOrDefault(value, fallback) {
return value === 0 || value ? String(value) : String(fallback);
}

View File

@ -1,17 +1,11 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormList,
AdminFormListRow,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { useState } from "react";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -22,99 +16,46 @@ import {
AdminListPage,
AdminListToolbar,
AdminRowActions,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import {
achievementStatusLabels,
achievementStatusOptions,
achievementTypeLabels,
achievementTypeOptions,
autoPinPolicyLabels,
autoPinPolicyOptions,
collectionTypeFilterOptions,
collectionTypeLabels,
collectionTypeOptions,
targetUnitLabels,
targetUnitOptions,
} from "@/features/achievements/constants.js";
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
import styles from "@/features/achievements/achievements.module.css";
const baseColumns = [
{
key: "achievement",
label: "成就",
width: "minmax(260px, 1.4fr)",
render: (achievement) => <AchievementIdentity achievement={achievement} />,
},
{
key: "condition",
label: "达成条件",
width: "minmax(240px, 1.2fr)",
render: (achievement) => <ConditionSummary conditions={achievement.conditions} />,
},
{
key: "badge",
label: "展示徽章",
width: "minmax(190px, 0.9fr)",
label: "徽章",
width: "96px",
},
{
key: "reward",
label: "奖励资源组",
width: "minmax(190px, 0.9fr)",
key: "title",
label: "成就名称",
width: "minmax(180px, 1fr)",
render: (achievement) => <span className={styles.name}>{achievement.title || achievement.achievementId}</span>,
},
{
key: "display",
label: "展示策略",
width: "minmax(150px, 0.7fr)",
key: "achievementType",
label: "成就类型",
width: "minmax(140px, 0.55fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
</div>
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
),
},
{
key: "effective",
label: "有效期",
width: "minmax(220px, 1.05fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>
{achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"}
</span>
</div>
),
},
{
key: "version",
label: "版本 / 排序",
width: "minmax(120px, 0.6fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>v{achievement.version || 1}</span>
<span className={styles.meta}>{achievement.sortOrder || 0}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.55fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.8fr)",
render: (achievement) => formatMillis(achievement.updatedAtMs || achievement.createdAtMs),
key: "description",
label: "成就描述",
width: "minmax(260px, 1.4fr)",
render: (achievement) => achievement.description || <span className={styles.emptyValue}>未填写</span>,
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.35fr)",
width: "112px",
},
];
@ -128,24 +69,10 @@ export function AchievementConfigPage() {
return {
...column,
render: (achievement) => (
<ResourceLabel id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
<BadgeCover id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
),
};
}
if (column.key === "reward") {
return {
...column,
render: (achievement) => (
<ResourceGroupLabel groups={page.resourceGroups} id={achievement.rewardResourceGroupId} />
),
};
}
if (column.key === "status") {
return {
...column,
render: (achievement) => <AchievementStatusSwitch achievement={achievement} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
@ -161,20 +88,12 @@ export function AchievementConfigPage() {
filters={
<>
<AdminFilterSelect
label="合集类型"
label="成就类型"
options={collectionTypeFilterOptions}
value={page.collectionType}
onChange={page.changeCollectionType}
/>
<AdminSearchBox
placeholder="搜索合集 ID"
value={page.collectionId}
onChange={page.changeCollectionId}
/>
<AdminFilterResetButton
disabled={!page.collectionType && !page.collectionId}
onClick={page.resetFilters}
/>
<AdminFilterResetButton disabled={!page.collectionType} onClick={page.resetFilters} />
</>
}
actions={
@ -190,7 +109,7 @@ export function AchievementConfigPage() {
<DataTable
columns={columns}
items={items}
minWidth="1710px"
minWidth="900px"
pagination={
total > 0
? {
@ -219,56 +138,6 @@ export function AchievementConfigPage() {
);
}
function AchievementIdentity({ achievement }) {
return (
<div className={styles.identity}>
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{achievement.title || achievement.achievementId}</span>
<span className={styles.meta}>{achievement.achievementId}</span>
<span className={styles.meta}>
{collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"}
</span>
</div>
</div>
);
}
function ConditionSummary({ conditions = [] }) {
const first = conditions[0] || {};
if (!first.metricType) {
return <span className={styles.emptyValue}>未配置</span>;
}
return (
<div className={styles.stack}>
<span>{first.metricType}</span>
<span className={styles.meta}>
{formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)}
{conditions.length > 1 ? ` · 共 ${conditions.length}` : ""}
</span>
</div>
);
}
function AchievementStatusSwitch({ achievement, page }) {
const checked = achievement.status === "active";
const disabled =
!page.abilities.canUpdate ||
achievement.status === "archived" ||
page.loadingAction === `status-${achievement.achievementId}`;
return (
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
disabled={disabled}
inputProps={{ "aria-label": checked ? "暂停成就" : "启用成就" }}
onChange={(event) => page.toggleAchievement(achievement, event.target.checked)}
/>
<span className={styles.meta}>{achievementStatusLabel(achievement.status)}</span>
</div>
);
}
function AchievementRowActions({ achievement, page }) {
return (
<AdminRowActions>
@ -279,6 +148,13 @@ function AchievementRowActions({ achievement, page }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canUpdate || page.loadingAction === `delete-${achievement.achievementId}`}
label="删除成就"
onClick={() => page.deleteAchievement(achievement)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
@ -297,20 +173,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
<BadgeResourceSelectField
disabled={disabled}
label="合集类型"
required
select
value={form.collectionType}
onChange={changeField(page.setForm, "collectionType")}
>
{collectionTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
label="选择徽章资源"
resources={page.badgeResources}
value={form.primaryBadgeResourceId}
onChange={(value) => page.setForm((current) => ({ ...current, primaryBadgeResourceId: value }))}
/>
<TextField
disabled={disabled}
label="成就类型"
@ -325,26 +194,6 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="合集 ID"
value={form.collectionId}
onChange={changeField(page.setForm, "collectionId")}
/>
<TextField
disabled={disabled}
label="状态"
required
select
value={form.status}
onChange={changeField(page.setForm, "status")}
>
{achievementStatusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="成就名称"
@ -360,189 +209,90 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection
actions={
<AdminActionIconButton
disabled={disabled}
label="添加条件"
type="button"
onClick={page.addCondition}
>
<Add fontSize="small" />
</AdminActionIconButton>
}
title="达成条件"
>
<AdminFormList>
{form.conditions.map((condition, index) => (
<AdminFormListRow
key={index}
columns="minmax(180px, 1fr) minmax(110px, 0.55fr) minmax(120px, 0.6fr) minmax(220px, 1.1fr) 44px"
>
<TextField
disabled={disabled}
label="指标类型"
required
value={condition.metricType}
onChange={(event) => page.updateCondition(index, "metricType", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="目标值"
required
type="number"
value={condition.targetValue}
onChange={(event) => page.updateCondition(index, "targetValue", event.target.value)}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={condition.targetUnit}
onChange={(event) => page.updateCondition(index, "targetUnit", event.target.value)}
>
{targetUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="维度过滤 JSON"
value={condition.dimensionFilterJson}
onChange={(event) =>
page.updateCondition(index, "dimensionFilterJson", event.target.value)
}
/>
<AdminActionIconButton
disabled={disabled || form.conditions.length <= 1}
label="删除条件"
type="button"
onClick={() => page.removeCondition(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminFormListRow>
))}
</AdminFormList>
</AdminFormSection>
<AdminFormSection title="徽章与奖励">
<AdminFormFieldGrid>
<ResourceSelect
disabled={disabled}
label="达成展示徽章"
resources={page.badgeResources}
value={form.primaryBadgeResourceId}
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
/>
<ResourceGroupSelectField
allowEmpty
disabled={disabled}
groups={page.resourceGroups}
label="达成奖励资源组"
value={form.rewardResourceGroupId}
onChange={(value) => page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))}
/>
<TextField
disabled={disabled}
label="展示策略"
select
value={form.autoPinPolicy}
onChange={changeField(page.setForm, "autoPinPolicy")}
>
{autoPinPolicyOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<AdminFormSwitchField
checked={form.honorVisibility}
checkedLabel="显示"
disabled={disabled}
label="荣誉墙"
uncheckedLabel="隐藏"
onChange={(checked) => page.setForm((current) => ({ ...current, honorVisibility: checked }))}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="时间与展示">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="开始时间"
type="datetime-local"
value={form.effectiveFrom}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveFrom")}
/>
<TextField
disabled={disabled}
label="结束时间"
type="datetime-local"
value={form.effectiveTo}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveTo")}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeField(page.setForm, "sortOrder")}
/>
<TextField
disabled={disabled}
label="展示配置 JSON"
value={form.displayConfigJson}
onChange={changeField(page.setForm, "displayConfigJson")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceSelect({ disabled, label, onChange, resources, value }) {
const options = withSelectedResource(resources, value);
function BadgeCover({ id, resources }) {
const resource = findResource(resources, id);
const imageUrl = resourceImageUrl(resource);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
<span className={styles.badgeCover}>
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
</span>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function BadgeResourceSelectField({ disabled, label, onChange, resources, value }) {
const [open, setOpen] = useState(false);
const normalizedValue = String(value || "");
const selected = findResource(resources, normalizedValue);
const displayValue = selected ? resourceName(selected) : normalizedValue ? `资源 #${normalizedValue}` : "";
const options = withSelectedResource(resources, normalizedValue);
const openDrawer = () => {
if (!disabled) {
setOpen(true);
}
};
const selectResource = (resourceId) => {
onChange(String(resourceId || ""));
setOpen(false);
};
function ResourceGroupLabel({ groups, id }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const group = groups.find((item) => Number(item.groupId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
<>
<TextField
fullWidth
className={styles.resourceField}
disabled={disabled}
label={label}
required
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
className={styles.resourceDrawer}
contentClassName={styles.resourceDrawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open}
title="选择徽章资源"
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.resourceGrid}>
{options.map((resource) => {
const resourceValue = String(resource.resourceId);
const selectedResource = resourceValue === normalizedValue;
return (
<button
className={[styles.resourceCard, selectedResource ? styles.resourceCardSelected : ""]
.filter(Boolean)
.join(" ")}
key={resource.resourceId}
type="button"
onClick={() => selectResource(resource.resourceId)}
>
<BadgeCover id={resource.resourceId} resources={options} />
<span className={styles.resourceName}>{resourceName(resource)}</span>
<span className={styles.meta}>
{resource.resourceCode || `ID ${resource.resourceId}`}
</span>
</button>
);
})}
{!options.length ? <div className={styles.emptyState}>当前无数据</div> : null}
</div>
</SideDrawer>
</>
);
}
@ -561,26 +311,18 @@ function withSelectedResource(resources, value) {
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function achievementStatusLabel(status) {
return achievementStatusLabels[status] || status || "-";
}
function achievementTypeLabel(type) {
return achievementTypeLabels[type] || type || "-";
}
function autoPinPolicyLabel(policy) {
return autoPinPolicyLabels[policy] || policy || "-";
function findResource(resources, id) {
return resources.find((item) => Number(item.resourceId) === Number(id));
}
function collectionTypeLabel(type) {
return collectionTypeLabels[type] || type || "-";
function resourceImageUrl(resource) {
return resource?.previewUrl || resource?.assetUrl || resource?.animationUrl || "";
}
function targetUnitLabel(unit) {
return targetUnitLabels[unit] || unit || "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
function resourceName(resource) {
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
}

View File

@ -2,60 +2,17 @@ import { z } from "zod";
const numberInput = z.union([z.string(), z.number()]);
const optionalNumberInput = numberInput.optional();
const statusSchema = z.enum(["draft", "active", "paused", "archived"]);
const collectionTypeSchema = z.enum(["ordinary", "activity_limited"]);
const achievementTypeSchema = z.enum(["ordinary", "activity_limited"]);
const autoPinPolicySchema = z.enum(["none", "if_empty", "always"]);
const conditionSchema = z.object({
dimensionFilterJson: z.string().optional(),
metricType: z.string().trim().min(1, "请输入指标类型").max(80, "指标类型不能超过 80 个字符"),
targetUnit: z.string().trim().min(1, "请选择目标单位").max(32, "目标单位不能超过 32 个字符"),
targetValue: numberInput,
});
export const achievementFormSchema = z
.object({
achievementType: achievementTypeSchema,
autoPinPolicy: autoPinPolicySchema,
collectionId: z.string().trim().max(80, "合集 ID 不能超过 80 个字符").optional(),
collectionType: collectionTypeSchema,
conditions: z.array(conditionSchema).min(1, "至少配置一个达成条件").max(10, "最多配置 10 个达成条件"),
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
displayConfigJson: z.string().optional(),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
honorVisibility: z.boolean(),
primaryBadgeResourceId: optionalNumberInput,
rewardResourceGroupId: optionalNumberInput,
sortOrder: optionalNumberInput,
status: statusSchema,
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
})
.superRefine((value, context) => {
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "奖励资源组必须是有效资源组");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
validateJsonObject(context, "displayConfigJson", value.displayConfigJson, "展示配置 JSON 不正确");
const fromMs = datetimeToMs(value.effectiveFrom);
const toMs = datetimeToMs(value.effectiveTo);
if (fromMs > 0 && toMs > 0 && toMs <= fromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
value.conditions.forEach((condition, index) => {
validatePositiveInteger(
context,
`conditions.${index}.targetValue`,
condition.targetValue,
"目标值必须是正整数",
);
validateJsonObject(
context,
`conditions.${index}.dimensionFilterJson`,
condition.dimensionFilterJson,
"维度过滤 JSON 不正确",
);
});
validateRequiredId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "请选择徽章资源");
});
export type AchievementForm = z.infer<typeof achievementFormSchema>;
@ -67,41 +24,11 @@ function validatePositiveInteger(context: z.RefinementCtx, path: string, value:
}
}
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
function validateRequiredId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value ?? "").trim();
if (!raw) {
context.addIssue({ code: "custom", message, path: [path] });
return;
}
validatePositiveInteger(context, path, raw, message);
}
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric)) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateJsonObject(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value || "").trim();
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
} catch {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
}
function datetimeToMs(value: unknown) {
const raw = String(value || "").trim();
if (!raw) {
return 0;
}
const parsed = new Date(raw).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@ -60,6 +60,68 @@ test("listPrettyDisplayIDs uses admin endpoint and normalizes snake case fields"
});
});
test("listPrettyDisplayIDs normalizes assigned user and operator snapshots", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
assigned_user: {
avatar: "https://cdn.example/u.png",
default_display_user_id: "10001",
display_user_id: "777777",
user_id: "9001",
username: "pretty owner",
},
assigned_user_id: "9001",
created_by_admin_id: "7",
display_user_id: "777777",
operator: {
account: "ops",
admin_id: "8",
name: "运营",
type: "admin",
},
pretty_id: "pretty-2",
source: "admin_grant",
status: "assigned",
updated_by_admin_id: "8",
},
],
page: 1,
page_size: 20,
total: 1,
},
}),
),
),
);
const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 });
expect(result.items[0]).toMatchObject({
assignedUser: {
avatar: "https://cdn.example/u.png",
defaultDisplayUserId: "10001",
displayUserId: "777777",
userId: "9001",
username: "pretty owner",
},
operator: {
account: "ops",
adminId: "8",
name: "运营",
type: "admin",
},
updatedByAdminId: "8",
});
});
test("listAppUsers sends country region and time filters", async () => {
vi.stubGlobal(
"fetch",

View File

@ -386,6 +386,8 @@ interface RawPrettyDisplayID {
assignedAtMs?: number;
assigned_lease_id?: string;
assignedLeaseId?: string;
assigned_user?: RawAppUserBrief;
assignedUser?: RawAppUserBrief;
assigned_user_id?: EntityId;
assignedUserId?: EntityId;
created_at_ms?: number;
@ -396,6 +398,7 @@ interface RawPrettyDisplayID {
displayUserId?: string;
generated_batch_id?: string;
generatedBatchId?: string;
operator?: RawPrettyDisplayIDOperator | null;
pool?: RawPrettyDisplayIDPool | null;
pool_id?: string;
poolId?: string;
@ -409,6 +412,17 @@ interface RawPrettyDisplayID {
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
updated_by_admin_id?: EntityId;
updatedByAdminId?: EntityId;
}
interface RawPrettyDisplayIDOperator extends RawAppUserBrief {
account?: string;
admin_id?: EntityId;
adminId?: EntityId;
name?: string;
source?: string;
type?: string;
}
interface RawPrettyDisplayIDGenerationBatch {
@ -572,6 +586,37 @@ function normalizeAppUserBanOperator(item?: RawAppUserBanOperator | null) {
};
}
function normalizePrettyDisplayIDOperator(item?: RawPrettyDisplayIDOperator | null, fallbackAdminId?: EntityId) {
if (!item) {
const adminId = stringValue(fallbackAdminId);
return adminId
? {
adminId,
name: "后台管理员",
type: "admin",
}
: undefined;
}
const user = normalizeAppUserBrief(item);
const adminId = stringValue(item.adminId ?? item.admin_id ?? fallbackAdminId);
return {
...user,
account: stringValue(item.account),
adminId,
name: stringValue(item.name),
source: stringValue(item.source),
type: stringValue(item.type || (adminId ? "admin" : "app_user")),
};
}
function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) {
if (!item) {
return undefined;
}
const user = normalizeAppUserBrief(item);
return user.userId || user.displayUserId || user.username ? user : undefined;
}
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
return {
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
@ -640,15 +685,19 @@ function numberValue(value: unknown): number {
// 靓号明细包含池号和后台发放两类来源pool 可能为空,归一化后页面只判断 source/status 即可。
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";
const updatedByAdminId = item.updatedByAdminId ?? item.updated_by_admin_id ?? "";
return {
appCode: item.appCode ?? item.app_code ?? "",
assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0,
assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "",
assignedUser: normalizeOptionalAppUserBrief(item.assignedUser ?? item.assigned_user),
assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "",
createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0,
createdByAdminId: item.createdByAdminId ?? item.created_by_admin_id ?? "",
createdByAdminId,
displayUserId: item.displayUserId ?? item.display_user_id ?? "",
generatedBatchId: item.generatedBatchId ?? item.generated_batch_id ?? "",
operator: normalizePrettyDisplayIDOperator(item.operator, updatedByAdminId || createdByAdminId),
pool: item.pool ? normalizePrettyDisplayIDPool(item.pool) : null,
poolId: item.poolId ?? item.pool_id ?? "",
prettyId: item.prettyId ?? item.pretty_id ?? "",
@ -657,6 +706,7 @@ function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto
source: item.source ?? "",
status: item.status ?? "",
updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0,
updatedByAdminId,
};
}

View File

@ -34,6 +34,39 @@
gap: 0;
}
.prettyTabsBar {
display: flex;
min-height: 48px;
align-items: center;
flex: 0 0 auto;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.prettyTabsBar :global(.MuiTabs-indicator) {
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--primary);
}
.prettyTab.prettyTab {
min-height: 48px;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 750;
letter-spacing: 0;
text-transform: none;
}
.prettyTab.prettyTab:hover {
color: var(--text-primary);
}
.prettyTab.prettyTab:global(.Mui-selected) {
color: var(--primary);
}
.prettyWorkspace {
display: grid;
flex: 1;
@ -637,6 +670,10 @@
}
@media (max-width: 760px) {
.prettyTabsBar {
padding: 0 var(--space-3);
}
.prettyWorkspace {
grid-template-columns: 1fr;
}

View File

@ -46,6 +46,7 @@ export function usePrettyIdsPage() {
const [idSource, setIdSource] = useState("");
const [idPoolId, setIdPoolId] = useState("");
const [assignedUserId, setAssignedUserId] = useState("");
const [activeTab, setActiveTab] = useState("ids");
const [activeDialog, setActiveDialog] = useState("");
const [activePool, setActivePool] = useState(null);
const [activePrettyID, setActivePrettyID] = useState(null);
@ -299,6 +300,7 @@ export function usePrettyIdsPage() {
activeDialog,
activePool,
activePrettyID,
activeTab,
assignedUserId,
changeAssignedUserId,
changeIDKeyword,
@ -331,6 +333,7 @@ export function usePrettyIdsPage() {
reloadAll,
resetIDFilters,
resetPoolFilters,
setActiveTab,
setGenerateForm,
setGrantForm,
setIdPage,

View File

@ -5,14 +5,16 @@ import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import MenuItem from "@mui/material/MenuItem";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
prettyIdSourceLabels,
@ -31,199 +33,22 @@ export function AppUserPrettyIdsPage() {
const page = usePrettyIdsPage();
const pools = page.poolQuery.data.items || [];
const prettyIds = page.idQuery.data.items || [];
const showingPools = page.activeTab === "pools";
return (
<section className={styles.root}>
<div className={`${styles.contentPanel} ${styles.prettyContentPanel}`}>
<div className={styles.prettyWorkspace}>
<section className={`${styles.prettyPane} ${styles.prettyPoolPane}`}>
<PaneHeader
actions={
<>
{page.abilities.canPrettyGenerate ? (
<Button
startIcon={<AutoAwesomeOutlined fontSize="small" />}
onClick={() => page.openGenerate(null)}
>
批量生成
</Button>
) : null}
{page.abilities.canPrettyUpdate ? (
<Button
startIcon={<AddCircleOutlineOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreatePool}
>
新增池
</Button>
) : null}
</>
}
count={page.poolQuery.data.total}
title="靓号池"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.compactControl}
label="等级轨道"
select
size="small"
value={page.poolTrack}
onChange={(event) => page.changePoolTrack(event.target.value)}
>
<MenuItem value="">全部等级轨道</MenuItem>
{prettyLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.poolStatus}
onChange={(event) => page.changePoolStatus(event.target.value)}
>
{prettyPoolStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetPoolFilters}>
重置
</Button>
</div>
<DataState
error={page.poolQuery.error}
loading={page.poolQuery.loading}
onRetry={page.poolQuery.reload}
>
<DataTable
className={styles.compactTableFrame}
columns={poolColumns(page)}
items={pools}
minWidth="720px"
pagination={
page.poolQuery.data.total > 0
? {
page: page.poolPage,
pageSize: page.poolQuery.data.pageSize || 20,
total: page.poolQuery.data.total,
onPageChange: page.setPoolPage,
}
: undefined
}
rowKey={(pool) => pool.poolId}
/>
</DataState>
</section>
<section className={`${styles.prettyPane} ${styles.prettyIDPane}`}>
<PaneHeader
actions={
page.abilities.canPrettyGrant ? (
<Button
startIcon={<SendOutlined fontSize="small" />}
variant="primary"
onClick={page.openGrant}
>
后台发放
</Button>
) : null
}
count={page.idQuery.data.total}
title="靓号列表"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.search}
label="搜索"
size="small"
value={page.idKeyword}
onChange={(event) => page.changeIDKeyword(event.target.value)}
/>
<TextField
className={styles.compactControl}
label="靓号池"
select
size="small"
value={page.idPoolId}
onChange={(event) => page.changeIDPool(event.target.value)}
>
<MenuItem value="">全部靓号池</MenuItem>
{page.poolOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="来源"
select
size="small"
value={page.idSource}
onChange={(event) => page.changeIDSource(event.target.value)}
>
{prettyIdSourceOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.idStatus}
onChange={(event) => page.changeIDStatus(event.target.value)}
>
{prettyIdStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.userIDControl}
label="占用用户"
size="small"
value={page.assignedUserId}
onChange={(event) => page.changeAssignedUserId(event.target.value)}
/>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetIDFilters}>
重置
</Button>
</div>
<DataState
error={page.idQuery.error}
loading={page.idQuery.loading}
onRetry={page.idQuery.reload}
>
<DataTable
className={styles.compactTableFrame}
columns={idColumns(page)}
items={prettyIds}
minWidth="980px"
pagination={
page.idQuery.data.total > 0
? {
page: page.idPage,
pageSize: page.idQuery.data.pageSize || 20,
total: page.idQuery.data.total,
onPageChange: page.setIdPage,
}
: undefined
}
rowKey={(item) => item.prettyId}
/>
</DataState>
</section>
<div className={styles.prettyTabsBar}>
<Tabs value={page.activeTab} onChange={(_, value) => page.setActiveTab(value)}>
<Tab className={styles.prettyTab} label={`靓号列表 ${Number(page.idQuery.data.total || 0)}`} value="ids" />
<Tab className={styles.prettyTab} label={`靓号池 ${Number(page.poolQuery.data.total || 0)}`} value="pools" />
</Tabs>
</div>
{showingPools ? (
<PrettyPoolPanel page={page} pools={pools} />
) : (
<PrettyIDPanel page={page} prettyIds={prettyIds} />
)}
</div>
<PoolDialog page={page} />
@ -234,6 +59,190 @@ export function AppUserPrettyIdsPage() {
);
}
function PrettyPoolPanel({ page, pools }) {
return (
<section className={styles.prettyPane}>
<PaneHeader
actions={
<>
{page.abilities.canPrettyGenerate ? (
<Button
startIcon={<AutoAwesomeOutlined fontSize="small" />}
onClick={() => page.openGenerate(null)}
>
批量生成
</Button>
) : null}
{page.abilities.canPrettyUpdate ? (
<Button
startIcon={<AddCircleOutlineOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreatePool}
>
新增池
</Button>
) : null}
</>
}
count={page.poolQuery.data.total}
title="靓号池"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.compactControl}
label="等级轨道"
select
size="small"
value={page.poolTrack}
onChange={(event) => page.changePoolTrack(event.target.value)}
>
<MenuItem value="">全部等级轨道</MenuItem>
{prettyLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.poolStatus}
onChange={(event) => page.changePoolStatus(event.target.value)}
>
{prettyPoolStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetPoolFilters}>
重置
</Button>
</div>
<DataState error={page.poolQuery.error} loading={page.poolQuery.loading} onRetry={page.poolQuery.reload}>
<DataTable
className={styles.compactTableFrame}
columns={poolColumns(page)}
items={pools}
minWidth="860px"
pagination={
page.poolQuery.data.total > 0
? {
page: page.poolPage,
pageSize: page.poolQuery.data.pageSize || 20,
total: page.poolQuery.data.total,
onPageChange: page.setPoolPage,
}
: undefined
}
rowKey={(pool) => pool.poolId}
/>
</DataState>
</section>
);
}
function PrettyIDPanel({ page, prettyIds }) {
return (
<section className={styles.prettyPane}>
<PaneHeader
actions={
page.abilities.canPrettyGrant ? (
<Button startIcon={<SendOutlined fontSize="small" />} variant="primary" onClick={page.openGrant}>
后台发放
</Button>
) : null
}
count={page.idQuery.data.total}
title="靓号列表"
/>
<div className={styles.prettyFilters}>
<TextField
className={styles.search}
label="搜索"
size="small"
value={page.idKeyword}
onChange={(event) => page.changeIDKeyword(event.target.value)}
/>
<TextField
className={styles.compactControl}
label="靓号池"
select
size="small"
value={page.idPoolId}
onChange={(event) => page.changeIDPool(event.target.value)}
>
<MenuItem value="">全部靓号池</MenuItem>
{page.poolOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="来源"
select
size="small"
value={page.idSource}
onChange={(event) => page.changeIDSource(event.target.value)}
>
{prettyIdSourceOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.compactControl}
label="状态"
select
size="small"
value={page.idStatus}
onChange={(event) => page.changeIDStatus(event.target.value)}
>
{prettyIdStatusOptions.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
className={styles.userIDControl}
label="占用用户"
size="small"
value={page.assignedUserId}
onChange={(event) => page.changeAssignedUserId(event.target.value)}
/>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetIDFilters}>
重置
</Button>
</div>
<DataState error={page.idQuery.error} loading={page.idQuery.loading} onRetry={page.idQuery.reload}>
<DataTable
className={styles.compactTableFrame}
columns={idColumns(page)}
items={prettyIds}
minWidth="1360px"
pagination={
page.idQuery.data.total > 0
? {
page: page.idPage,
pageSize: page.idQuery.data.pageSize || 20,
total: page.idQuery.data.total,
onPageChange: page.setIdPage,
}
: undefined
}
rowKey={(item) => item.prettyId}
/>
</DataState>
</section>
);
}
function PaneHeader({ actions, count, title }) {
return (
<div className={styles.prettyPaneHeader}>
@ -306,13 +315,12 @@ function idColumns(page) {
{
key: "display",
label: "靓号",
width: "minmax(190px, 1fr)",
width: "minmax(130px, 0.7fr)",
render: (item) => (
<div className={styles.stack}>
<div className={styles.nameLine}>
<span className={styles.name}>{item.displayUserId || "-"}</span>
</div>
<div className={styles.meta}>{item.prettyId}</div>
</div>
),
},
@ -334,21 +342,25 @@ function idColumns(page) {
render: (item) => <StatusBadge labels={prettyIdStatusLabels} status={item.status} />,
},
{
key: "assigned",
label: "占用 / 时间",
width: "minmax(190px, 0.9fr)",
key: "assignedUser",
label: "占用",
width: "minmax(240px, 1.2fr)",
render: (item) => (
<div className={styles.stack}>
<AssignedUserCell item={item} />
<span className={styles.meta}>
{item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)}
</span>
<span className={styles.meta}>
{item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"}
</span>
</div>
<AssignedUserCell item={item} />
),
},
{
key: "operator",
label: "操作人",
width: "minmax(210px, 1fr)",
render: (item) => <OperatorCell item={item} />,
},
{
key: "time",
label: "占用 / 释放时间",
width: "minmax(210px, 1fr)",
render: (item) => <PrettyIDTimeCell item={item} />,
},
{
key: "actions",
label: "操作",
@ -368,31 +380,51 @@ function idColumns(page) {
}
function AssignedUserCell({ item }) {
const { openAppUserDetail } = useAppUserDetail();
const assignedUserId = formatEntityID(item.assignedUserId);
if (!item.assignedUserId || !openAppUserDetail) {
return <span>{assignedUserId}</span>;
if (!item.assignedUserId) {
return <span className={styles.meta}>-</span>;
}
return <AdminUserIdentity openInAppUserDetail rows={[assignedUserId]} user={assignedUser(item)} />;
}
function OperatorCell({ item }) {
const operator = item.operator || {};
const adminId = operator.adminId || item.updatedByAdminId || item.createdByAdminId;
if (!operator.userId && !operator.adminId && !operator.name && !operator.account && !adminId) {
return <span className={styles.meta}>-</span>;
}
if ((operator.type || "admin") === "app_user") {
return <AdminUserIdentity openInAppUserDetail rows={[operator.displayUserId, operator.userId]} user={operator} />;
}
return (
<button
className={styles.countryButton}
type="button"
onClick={(event) => {
event.stopPropagation();
openAppUserDetail({
displayUserId: assignedUserId,
prettyDisplayUserId: item.displayUserId,
prettyId: item.prettyId,
userId: item.assignedUserId,
});
}}
>
{assignedUserId}
</button>
<AdminUserIdentity
name={operator.account || operator.name || "后台管理员"}
rows={[operator.name, adminId ? `Admin ID ${adminId}` : "后台管理员"]}
user={{ ...operator, adminId }}
/>
);
}
function PrettyIDTimeCell({ item }) {
return (
<div className={styles.stack}>
<span>{item.assignedAtMs ? formatMillis(item.assignedAtMs) : "-"}</span>
<span className={styles.meta}>{item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"}</span>
</div>
);
}
function assignedUser(item) {
return {
...(item.assignedUser || {}),
displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId,
prettyDisplayUserId: item.displayUserId,
prettyId: item.prettyId,
userId: item.assignedUser?.userId || item.assignedUserId,
};
}
function PoolDialog({ page }) {
const editing = Boolean(page.activePool);
return (

View File

@ -0,0 +1,169 @@
import { render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { AppUserPrettyIdsPage } from "./AppUserPrettyIdsPage.jsx";
import { usePrettyIdsPage } from "@/features/app-users/hooks/usePrettyIdsPage.js";
vi.mock("@/features/app-users/hooks/usePrettyIdsPage.js", () => ({
usePrettyIdsPage: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("pretty id page uses tabs and hides internal pretty record ids", () => {
vi.mocked(usePrettyIdsPage).mockReturnValue(pageFixture());
render(<AppUserPrettyIdsPage />);
expect(screen.getByRole("tab", { name: "靓号列表 1" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "靓号池 1" })).toBeInTheDocument();
expect(screen.getAllByText("777777").length).toBeGreaterThan(0);
expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.toBeInTheDocument();
expect(screen.getByText("占用人 A")).toBeInTheDocument();
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument();
});
test("pretty id page can switch to pool tab", () => {
const page = pageFixture({ activeTab: "pools" });
vi.mocked(usePrettyIdsPage).mockReturnValue(page);
render(<AppUserPrettyIdsPage />);
expect(screen.getByRole("tab", { name: "靓号池 1" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("≥LV.10")).toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: {
canPrettyGenerate: true,
canPrettyGrant: true,
canPrettyUpdate: true,
},
activeDialog: "",
activePool: null,
activePrettyID: null,
activeTab: "ids",
assignedUserId: "",
changeAssignedUserId: vi.fn(),
changeIDKeyword: vi.fn(),
changeIDPool: vi.fn(),
changeIDSource: vi.fn(),
changeIDStatus: vi.fn(),
changePoolStatus: vi.fn(),
changePoolTrack: vi.fn(),
closeDialog: vi.fn(),
generateForm: { count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" },
grantForm: { displayUserId: "", durationDays: "", reason: "", targetUserId: "" },
idKeyword: "",
idPage: 1,
idPoolId: "",
idQuery: {
data: {
items: [prettyIdFixture()],
page: 1,
pageSize: 20,
total: 1,
},
error: null,
loading: false,
reload: vi.fn(),
},
idSource: "",
idStatus: "",
loadingAction: "",
openCreatePool: vi.fn(),
openEditPool: vi.fn(),
openGenerate: vi.fn(),
openGrant: vi.fn(),
openStatus: vi.fn(),
poolForm: {
levelTrack: "wealth",
maxLevel: 19,
minLevel: 10,
name: "",
ruleConfigJson: "",
ruleType: "aabbcc",
sortOrder: 0,
status: "active",
},
poolOptions: [{ label: "≥LV.10 · 10-19", value: "pool-1" }],
poolPage: 1,
poolQuery: {
data: {
items: [poolFixture()],
page: 1,
pageSize: 20,
total: 1,
},
error: null,
loading: false,
reload: vi.fn(),
},
poolStatus: "",
poolTrack: "",
reloadAll: vi.fn(),
resetIDFilters: vi.fn(),
resetPoolFilters: vi.fn(),
setActiveTab: vi.fn(),
setGenerateForm: vi.fn(),
setGrantForm: vi.fn(),
setIdPage: vi.fn(),
setPoolForm: vi.fn(),
setPoolPage: vi.fn(),
setStatusForm: vi.fn(),
statusForm: { prettyId: "", reason: "", status: "available" },
submitGenerate: vi.fn(),
submitGrant: vi.fn(),
submitPool: vi.fn(),
submitStatus: vi.fn(),
...patch,
};
}
function poolFixture() {
return {
createdAtMs: 1781249886288,
levelTrack: "wealth",
maxLevel: 19,
minLevel: 10,
name: "≥LV.10",
poolId: "pretty_pool_1781249886288_301d1a449c51fbc0",
ruleType: "abccba",
status: "active",
updatedAtMs: 1781249886288,
};
}
function prettyIdFixture() {
return {
assignedAtMs: 1782499200000,
assignedUser: {
avatar: "",
defaultDisplayUserId: "10001",
displayUserId: "777777",
userId: "9001",
username: "占用人 A",
},
assignedUserId: "9001",
createdAtMs: 1782499100000,
createdByAdminId: "7",
displayUserId: "777777",
operator: {
account: "ops",
adminId: "8",
name: "运营",
type: "admin",
},
pool: poolFixture(),
poolId: "pool-1",
prettyId: "pretty_1782492525506_0b70bb6c6dbc63cf",
releasedAtMs: 1782493200000,
source: "admin_grant",
status: "assigned",
updatedAtMs: 1782499300000,
updatedByAdminId: "8",
};
}

View File

@ -9,10 +9,12 @@ export interface LevelRuleDto {
createdAtMs?: number;
displayConfigJson?: string;
level: number;
avatarFrameResourceId?: number;
longBadgeResourceId?: number;
name?: string;
requiredValue: number;
rewardResourceGroupId?: number;
shortBadgeResourceId?: number;
sortOrder?: number;
status?: LevelConfigStatus | string;
track: LevelTrackCode;
@ -59,10 +61,12 @@ export interface LevelTrackPayload {
}
export interface LevelRulePayload {
avatar_frame_resource_id?: number;
long_badge_resource_id?: number;
name: string;
required_value: number;
reward_resource_group_id?: number;
short_badge_resource_id?: number;
sort_order: number;
status: LevelConfigStatus;
}
@ -160,6 +164,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
displayConfigJson: jsonString(displayConfig),
level: numberValue(source.level),
avatarFrameResourceId: numberValue(
source.avatarFrameResourceId ??
source.avatar_frame_resource_id ??
displayConfigObject.avatarFrameResourceId ??
displayConfigObject.avatar_frame_resource_id,
),
longBadgeResourceId: numberValue(
source.longBadgeResourceId ??
source.long_badge_resource_id ??
@ -169,6 +179,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
name: stringValue(source.name),
requiredValue: numberValue(source.requiredValue ?? source.required_value),
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
shortBadgeResourceId: numberValue(
source.shortBadgeResourceId ??
source.short_badge_resource_id ??
displayConfigObject.shortBadgeResourceId ??
displayConfigObject.short_badge_resource_id,
),
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
status: stringValue(source.status),
track: stringValue(source.track) as LevelTrackCode,

View File

@ -14,12 +14,16 @@ import {
const emptyConfig = { serverTimeMs: 0, tracks: [] };
const defaultTrack = "wealth";
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
const badgeResourcePageQuery = { ...resourcePageQuery, resource_type: "badge" };
const avatarFrameResourcePageQuery = { ...resourcePageQuery, resource_type: "avatar_frame" };
export function useLevelConfigPage() {
const abilities = useLevelConfigAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(emptyConfig);
const [resources, setResources] = useState([]);
const [badgeResources, setBadgeResources] = useState([]);
const [avatarFrameResources, setAvatarFrameResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const [track, setTrack] = useState(defaultTrack);
const [loading, setLoading] = useState(true);
@ -37,13 +41,19 @@ export function useLevelConfigPage() {
setLoading(true);
setError("");
try {
const [nextConfig, resourcePage, groupPage] = await Promise.all([
const [nextConfig, badgeResourcePage, avatarFrameResourcePage, groupPage] = await Promise.all([
listLevelConfig(),
listResources(resourcePageQuery),
// 等级配置只需要徽章和头像框;后端分页按类型过滤,避免 active 资源超过第一页时漏掉等级徽章。
listResources(badgeResourcePageQuery),
listResources(avatarFrameResourcePageQuery),
listResourceGroups(resourcePageQuery),
]);
const nextBadgeResources = badgeResourcePage.items || [];
const nextAvatarFrameResources = avatarFrameResourcePage.items || [];
setConfig(nextConfig);
setResources(resourcePage.items || []);
setBadgeResources(nextBadgeResources);
setAvatarFrameResources(nextAvatarFrameResources);
setResources([...nextBadgeResources, ...nextAvatarFrameResources]);
setResourceGroups(groupPage.items || []);
} catch (err) {
setError(err.message || "加载等级配置失败");
@ -60,15 +70,6 @@ export function useLevelConfigPage() {
() => (config.tracks || []).filter((item) => item.track === track),
[config.tracks, track],
);
const badgeResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "badge"),
[resources],
);
const avatarFrameResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "avatar_frame"),
[resources],
);
const changeTrack = (value) => setTrack(value);
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
@ -245,11 +246,13 @@ function buildRuleForm(rule = null, levelTrack = null) {
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
return {
avatarFrameResourceId: optionalId(rule?.avatarFrameResourceId),
level: valueOrEmpty(nextLevel),
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}`,
requiredValue: valueOrEmpty(rule?.requiredValue),
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
shortBadgeResourceId: optionalId(rule?.shortBadgeResourceId),
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
status: rule?.status || "active",
track: trackCode,
@ -284,10 +287,12 @@ function buildTrackForm(levelTrack = null) {
function buildRulePayload(form) {
return {
avatar_frame_resource_id: Number(form.avatarFrameResourceId || 0),
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
name: String(form.name || "").trim(),
required_value: Number(form.requiredValue || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
short_badge_resource_id: Number(form.shortBadgeResourceId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
};

View File

@ -0,0 +1,85 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createTier: vi.fn(),
listLevelConfig: vi.fn(),
listResourceGroups: vi.fn(),
listResources: vi.fn(),
showToast: vi.fn(),
updateTier: vi.fn(),
updateTrack: vi.fn(),
upsertRule: vi.fn(),
}));
vi.mock("@/features/resources/api", () => ({
listResourceGroups: mocks.listResourceGroups,
listResources: mocks.listResources,
}));
vi.mock("@/features/level-config/api", () => ({
createTier: mocks.createTier,
listLevelConfig: mocks.listLevelConfig,
updateTier: mocks.updateTier,
updateTrack: mocks.updateTrack,
upsertRule: mocks.upsertRule,
}));
vi.mock("@/features/level-config/permissions.js", () => ({
useLevelConfigAbilities: () => ({ canUpdate: true, canView: true }),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: () => ({ showToast: mocks.showToast }),
}));
import { useLevelConfigPage } from "./useLevelConfigPage.js";
afterEach(() => {
vi.clearAllMocks();
});
test("loads level badges through backend resource type filtering", async () => {
const badgeResource = {
badgeForm: "strip",
badgeKind: "level",
levelTrack: "wealth",
name: "财富长徽章",
resourceId: 397,
resourceType: "badge",
};
const avatarFrameResource = {
name: "等级头像框",
resourceId: 812,
resourceType: "avatar_frame",
};
mocks.listLevelConfig.mockResolvedValue({
serverTimeMs: 0,
tracks: [{ rules: [], tiers: [], track: "wealth" }],
});
mocks.listResources.mockImplementation(async (query) => {
if (query.resource_type === "badge") {
return { items: [badgeResource], page: 1, pageSize: 500, total: 1 };
}
if (query.resource_type === "avatar_frame") {
return { items: [avatarFrameResource], page: 1, pageSize: 500, total: 1 };
}
throw new Error(`unexpected resource query ${JSON.stringify(query)}`);
});
mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 500, total: 0 });
const { result } = renderHook(() => useLevelConfigPage());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mocks.listResources).toHaveBeenCalledTimes(2);
expect(mocks.listResources).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, page_size: 500, resource_type: "badge", status: "active" }),
);
expect(mocks.listResources).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, page_size: 500, resource_type: "avatar_frame", status: "active" }),
);
expect(result.current.badgeResources).toEqual([badgeResource]);
expect(result.current.avatarFrameResources).toEqual([avatarFrameResource]);
});

View File

@ -222,6 +222,18 @@ function buildRuleColumns(page) {
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "avatarFrame",
label: "等级头像框",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.avatarFrameResourceId} resources={page.avatarFrameResources} />,
},
{
key: "shortBadge",
label: "短徽章素材",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.shortBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "reward",
label: "升级礼物",
@ -413,7 +425,15 @@ function RuleFormDialog({ page }) {
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setRuleForm, "track")}
onChange={(event) =>
page.setRuleForm((current) => ({
...current,
avatarFrameResourceId: "",
longBadgeResourceId: "",
shortBadgeResourceId: "",
track: event.target.value,
}))
}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -454,6 +474,20 @@ function RuleFormDialog({ page }) {
value={form.longBadgeResourceId}
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
/>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="等级头像框素材"
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
value={form.avatarFrameResourceId}
onChange={changeField(page.setRuleForm, "avatarFrameResourceId")}
/>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="短徽章素材"
resources={levelShortBadgeResourcesForTrack(page.badgeResources, form.track)}
value={form.shortBadgeResourceId}
onChange={changeField(page.setRuleForm, "shortBadgeResourceId")}
/>
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
@ -510,7 +544,13 @@ function TierFormDialog({ page }) {
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setTierForm, "track")}
onChange={(event) =>
page.setTierForm((current) => ({
...current,
displayAvatarFrameResourceId: "",
track: event.target.value,
}))
}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -547,7 +587,7 @@ function TierFormDialog({ page }) {
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="头像框素材"
resources={page.avatarFrameResources}
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
value={form.displayAvatarFrameResourceId}
onChange={changeField(page.setTierForm, "displayAvatarFrameResourceId")}
/>
@ -698,11 +738,37 @@ function levelBadgeResourcesForTrack(resources, track) {
});
}
function levelShortBadgeResourcesForTrack(resources, track) {
return (resources || []).filter((resource) => {
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
return badgeForm === "tile" && badgeKind === "level" && levelTrack === track;
});
}
function levelAvatarFrameResourcesForTrack(resources, track) {
return (resources || []).filter((resource) => {
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
return resource.resourceType === "avatar_frame" && avatarFrameKind === "level" && levelTrack === track;
});
}
function resourceMetaLabel(resource, id) {
if (resource.resourceType === "avatar_frame") {
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
if (avatarFrameKind === "level" && levelTrack) {
return `ID ${id} · 等级头像框 · ${levelTrackLabels[levelTrack] || levelTrack}`;
}
}
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) {
return `ID ${id} · 等级徽章 · ${levelTrackLabels[levelTrack] || levelTrack}`;
const badgeFormLabel = badgeForm === "tile" ? "短徽章" : "长徽章";
return `ID ${id} · ${badgeFormLabel} · ${levelTrackLabels[levelTrack] || levelTrack}`;
}
return `ID ${id}`;
}
@ -746,6 +812,21 @@ function badgeKindFromMetadata(metadataJson) {
}
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -17,11 +17,13 @@ export const levelTrackFormSchema = z
export const levelRuleFormSchema = z
.object({
avatarFrameResourceId: numberInput.optional(),
level: numberInput,
longBadgeResourceId: numberInput.optional(),
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
requiredValue: numberInput,
rewardResourceGroupId: numberInput.optional(),
shortBadgeResourceId: numberInput.optional(),
sortOrder: numberInput.optional(),
status: statusSchema,
track: trackSchema,
@ -29,8 +31,10 @@ export const levelRuleFormSchema = z
.superRefine((value, context) => {
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
validateOptionalId(context, "avatarFrameResourceId", value.avatarFrameResourceId, "等级头像框素材必须是有效资源");
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
validateOptionalId(context, "shortBadgeResourceId", value.shortBadgeResourceId, "短徽章素材必须是有效资源");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
});

View File

@ -7,6 +7,7 @@ import {
getGiftDiamondRatios,
getWheelConfig,
getWheelDrawSummary,
listCoinLedger,
listCoinAdjustments,
listCoinSellerLedger,
listWheelDraws,
@ -125,6 +126,29 @@ test("coin adjustment APIs use generated admin paths", async () => {
expect(createInit?.method).toBe("POST");
});
test("coin ledger API sends type filter", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await listCoinLedger({
biz_type: "game_debit",
page: 1,
page_size: 20,
user_keyword: "168359",
});
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-ledger?");
expect(String(listUrl)).toContain("user_keyword=168359");
expect(String(listUrl)).toContain("biz_type=game_debit");
expect(listInit?.method).toBe("GET");
});
test("coin seller ledger API uses generated admin path and filters", async () => {
vi.stubGlobal(
"fetch",
@ -175,7 +199,9 @@ test("coin seller ledger export API sends current filters without pagination", a
test("wheel APIs use activity paths and generated methods", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } }))),
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await getWheelConfig("default");

View File

@ -1,10 +1,6 @@
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -12,11 +8,47 @@ import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/operations/operations.module.css";
const pageSize = 50;
const coinLedgerBizTypeOptions = [
{ label: "人工入账", value: "manual_credit" },
{ label: "礼物扣费", value: "gift_debit" },
{ label: "私聊礼物扣费", value: "direct_gift_debit" },
{ label: "机器人礼物扣费", value: "robot_gift_debit" },
{ label: "币商转账", value: "coin_seller_transfer" },
{ label: "任务奖励", value: "task_reward" },
{ label: "幸运礼物奖励", value: "lucky_gift_reward" },
{ label: "转盘抽奖扣费", value: "wheel_draw_debit" },
{ label: "转盘奖励", value: "wheel_reward" },
{ label: "房间流水奖励", value: "room_turnover_reward" },
{ label: "邀请活动奖励", value: "invite_activity_reward" },
{ label: "开业活动奖励", value: "agency_opening_reward" },
{ label: "工资兑换金币", value: "salary_exchange_to_coin" },
{ label: "Google 充值", value: "google_play_recharge" },
{ label: "MifaPay 充值", value: "mifapay" },
{ label: "V5Pay 充值", value: "v5pay" },
{ label: "USDT 充值", value: "usdt_trc20" },
{ label: "游戏入账", value: "game_credit" },
{ label: "游戏扣费", value: "game_debit" },
{ label: "游戏退款", value: "game_refund" },
{ label: "游戏冲正", value: "game_reverse" },
{ label: "主播工资结算", value: "host_salary_settlement" },
{ label: "CP 解除费用", value: "cp_breakup_fee" },
{ label: "VIP 购买", value: "vip_purchase" },
{ label: "VIP 发放", value: "vip_grant" },
{ label: "资源发放", value: "resource_grant" },
{ label: "资源回收", value: "resource_grant_revoke" },
{ label: "资源商店购买", value: "resource_shop_purchase" },
{ label: "团队工资结算", value: "team_salary_settlement" },
];
const coinLedgerBizTypeLabels = Object.fromEntries(
coinLedgerBizTypeOptions.map((option) => [option.value, option.label]),
);
const columns = [
{
key: "user",
@ -110,16 +142,18 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
const [page, setPage] = useState(1);
const [selectedEntry, setSelectedEntry] = useState(null);
const [bizType, setBizType] = useState("");
const [userKeyword, setUserKeyword] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
biz_type: bizType,
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
user_keyword: fixedUserId || userKeyword,
}),
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
[bizType, fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
);
const query = usePaginatedQuery({
errorMessage: "获取金币流水失败",
@ -132,8 +166,12 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const hasMutableFilters = Boolean(userKeyword || timeRange.startMs || timeRange.endMs);
const hasMutableFilters = Boolean(userKeyword || bizType || timeRange.startMs || timeRange.endMs);
const changeBizType = (value) => {
setBizType(value);
setPage(1);
};
const changeUserKeyword = (value) => {
setUserKeyword(value);
setPage(1);
@ -143,6 +181,7 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
setPage(1);
};
const resetFilters = () => {
setBizType("");
setUserKeyword("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
@ -153,18 +192,31 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const tableColumns = columns
.filter((column) => !(column.key === "user" && fixedUserId))
.map((column) =>
column.key === "user"
? {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeUserKeyword,
}),
}
: column,
);
.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeUserKeyword,
}),
};
}
if (column.key === "bizType") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部",
options: coinLedgerBizTypeOptions,
placeholder: "流水类型",
value: bizType,
onChange: changeBizType,
}),
};
}
return column;
});
return (
<div className={styles.ledgerTableShell}>
@ -476,17 +528,5 @@ function metadataLabel(key) {
}
function bizTypeLabel(value) {
const labels = {
coin_seller_transfer: "币商转账",
game_credit: "游戏入账",
game_debit: "游戏扣费",
game_refund: "游戏退款",
game_reverse: "游戏冲正",
gift_debit: "礼物扣费",
manual_credit: "人工入账",
resource_grant: "资源发放",
task_reward: "任务奖励",
vip_purchase: "VIP 购买",
};
return labels[value] || value || "-";
return coinLedgerBizTypeLabels[value] || value || "-";
}

View File

@ -10,6 +10,8 @@ import type {
RechargeProductPayload,
TemporaryPaymentLinkDto,
ThirdPartyPaymentChannelDto,
ThirdPartyPaymentMethodSyncDto,
ThirdPartyPaymentMethodSyncPayload,
ThirdPartyPaymentMethodDto,
ThirdPartyPaymentRateSyncPayload,
ThirdPartyPaymentRateSyncDto,
@ -75,13 +77,10 @@ export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPayme
export function listTemporaryPaymentLinks(query: PageQuery = {}): Promise<ApiPage<TemporaryPaymentLinkDto>> {
const endpoint = API_ENDPOINTS.listTemporaryPaymentLinks;
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(
apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks),
{
method: endpoint.method,
query,
},
);
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks), {
method: endpoint.method,
query,
});
}
export function getTemporaryPaymentLink(orderId: EntityId): Promise<TemporaryPaymentLinkDto> {
@ -122,6 +121,19 @@ export function updateThirdPartyPaymentRate(
);
}
export function syncThirdPartyPaymentMethods(
payload: ThirdPartyPaymentMethodSyncPayload = { providerCode: "v5pay" },
): Promise<ThirdPartyPaymentMethodSyncDto> {
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentMethods;
return apiRequest<ThirdPartyPaymentMethodSyncDto, ThirdPartyPaymentMethodSyncPayload>(
apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentMethods),
{
body: payload,
method: endpoint.method,
},
);
}
export function syncThirdPartyPaymentRates(
payload: ThirdPartyPaymentRateSyncPayload = { markupPercent: 0 },
): Promise<ThirdPartyPaymentRateSyncDto> {

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import {
listThirdPartyPaymentChannels,
syncThirdPartyPaymentMethods,
setThirdPartyPaymentMethodStatus,
syncThirdPartyPaymentRates,
updateThirdPartyPaymentRate,
@ -90,6 +91,25 @@ export function useThirdPartyPaymentPage() {
setSyncRateDialogOpen(false);
};
const syncPaymentMethods = async (channel) => {
// 支付方式同步只开放给 V5PayMiFaPay 暂无可查询当前商户已开通方式的后端接口。
if (!abilities.canUpdateThirdParty || channel?.providerCode !== "v5pay") {
return;
}
const actionKey = `sync-methods:${channel.providerCode}`;
setLoadingAction(actionKey);
try {
const summary = await syncThirdPartyPaymentMethods({ providerCode: channel.providerCode });
const fresh = await result.reload();
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
showToast(paymentMethodSyncToast(summary), summary?.failedCountryCount ? "warning" : "success");
} catch (err) {
showToast(err.message || "同步支付方式失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleMethod = async (method, active) => {
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
if (!abilities.canUpdateThirdParty) {
@ -172,6 +192,7 @@ export function useThirdPartyPaymentPage() {
saveRate,
setRateDraft,
setSyncRateMarkupPercent,
syncPaymentMethods,
submitSyncRates,
syncRateDialogOpen,
syncRateForm,
@ -188,3 +209,13 @@ function rateDraftsFromChannels(channels) {
return drafts;
}, {});
}
function paymentMethodSyncToast(summary = {}) {
const created = summary.createdCount || 0;
const updated = summary.updatedCount || 0;
const failed = summary.failedCountryCount || 0;
const skipped = summary.skippedCount || 0;
const suffix = failed ? `,失败 ${failed} 个国家` : "";
const skippedSuffix = skipped ? `,跳过 ${skipped} 个方式` : "";
return `支付方式已同步:新增 ${created} 个,更新 ${updated}${skippedSuffix}${suffix}`;
}

View File

@ -53,7 +53,7 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
<div className={styles.thirdPartyLayout}>
<div className={styles.channelList}>
{page.channels.map((channel) => (
<button
<div
key={channel.providerCode}
className={[
styles.channelRow,
@ -63,22 +63,39 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
]
.filter(Boolean)
.join(" ")}
type="button"
onClick={() => page.toggleChannel(channel.providerCode)}
>
<span className={styles.channelExpandIcon}>
{page.expandedChannel === channel.providerCode ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
<span className={styles.channelText}>
<span>{channel.providerName || channel.providerCode}</span>
<span>{channel.providerCode}</span>
</span>
<StatusPill active={channel.status === "active"} />
</button>
<button
className={styles.channelSelectButton}
type="button"
onClick={() => page.toggleChannel(channel.providerCode)}
>
<span className={styles.channelExpandIcon}>
{page.expandedChannel === channel.providerCode ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
<span className={styles.channelText}>
<span>{channel.providerName || channel.providerCode}</span>
<span>{channel.providerCode}</span>
</span>
</button>
{channel.providerCode === "v5pay" ? (
<Button
disabled={
!page.abilities.canUpdateThirdParty ||
page.loadingAction === `sync-methods:${channel.providerCode}`
}
startIcon={<SyncOutlined fontSize="small" />}
onClick={() => page.syncPaymentMethods(channel)}
>
{page.loadingAction === `sync-methods:${channel.providerCode}`
? "同步中"
: "同步支付方式"}
</Button>
) : null}
</div>
))}
</div>
@ -196,12 +213,10 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
return <AdminListPage>{content}</AdminListPage>;
}
function StatusPill({ active }) {
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
}
function MethodLogo({ method }) {
const label = String(method.methodName || method.payType || method.payWay || "Pay").slice(0, 2).toUpperCase();
const label = String(method.methodName || method.payType || method.payWay || "Pay")
.slice(0, 2)
.toUpperCase();
if (method.logoUrl) {
return <img alt="" className={styles.methodLogo} src={method.logoUrl} />;
}

View File

@ -40,6 +40,23 @@ test("third-party payment rate dialog submits markup percent", () => {
expect(page.submitSyncRates).toHaveBeenCalledTimes(1);
});
test("third-party payment page shows v5pay method sync button instead of status text", () => {
const v5pay = { providerCode: "v5pay", providerName: "V5Pay", status: "active", methods: [] };
const page = pageFixture({
activeChannel: v5pay,
channels: [{ providerCode: "mifapay", providerName: "MiFaPay", status: "active", methods: [] }, v5pay],
syncPaymentMethods: vi.fn(),
});
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
renderPage();
expect(screen.queryByText("已启用")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "同步支付方式" }));
expect(page.syncPaymentMethods).toHaveBeenCalledWith(v5pay);
});
function renderPage() {
return render(
<MemoryRouter>
@ -66,6 +83,7 @@ function pageFixture(overrides = {}) {
saveRate: vi.fn(),
setRateDraft: vi.fn(),
setSyncRateMarkupPercent: vi.fn(),
syncPaymentMethods: vi.fn(),
submitSyncRates: vi.fn((event) => event.preventDefault()),
syncRateDialogOpen: false,
syncRateForm: { markupPercent: "0" },

View File

@ -131,9 +131,8 @@
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: 24px minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
text-align: left;
}
@ -143,6 +142,20 @@
background: var(--active-surface);
}
.channelSelectButton {
display: grid;
min-width: 0;
align-items: center;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
gap: var(--space-2);
grid-template-columns: 24px minmax(0, 1fr);
padding: 0;
text-align: left;
}
.channelExpandIcon {
display: inline-flex;
align-items: center;

View File

@ -17,6 +17,7 @@ export interface ResourceDto {
walletAssetAmount?: number;
priceType?: string;
coinPrice?: number;
avatarFrameKind?: string;
badgeForm?: string;
badgeKind?: string;
levelTrack?: string;
@ -34,6 +35,7 @@ export interface ResourcePayload {
amount: number;
animationUrl?: string;
assetUrl: string;
avatarFrameKind?: string;
badgeForm?: string;
badgeKind?: string;
coinPrice: number;

View File

@ -72,6 +72,13 @@ export const badgeKindOptions = [
export const badgeKindLabels = Object.fromEntries(badgeKindOptions);
export const avatarFrameKindOptions = [
["normal", "普通头像框"],
["level", "等级头像框"],
];
export const avatarFrameKindLabels = Object.fromEntries(avatarFrameKindOptions);
export const badgeLevelTrackOptions = [
["wealth", "财富等级"],
["game", "游戏等级"],

View File

@ -71,6 +71,10 @@ const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
animationUrl: resource.animationUrl || resource.assetUrl || "",
avatarFrameKind:
resource.resourceType === "avatar_frame"
? resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal"
: "normal",
badgeForm:
resource.resourceType === "badge"
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
@ -82,7 +86,7 @@ const emptyResourceForm = (resource = {}) => ({
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
enabled: resource.status ? resource.status === "active" : true,
levelTrack:
resource.resourceType === "badge"
resource.resourceType === "badge" || resource.resourceType === "avatar_frame"
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
: "",
managerGrantEnabled:
@ -1727,15 +1731,18 @@ function resetSetter(setValue, setPage) {
function buildResourcePayload(form) {
const resourceType = form.resourceType;
const isCoin = resourceType === "coin";
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
const animationUrl = form.animationUrl.trim();
return {
amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl,
assetUrl: animationUrl,
avatarFrameKind: resourceType === "avatar_frame" ? form.avatarFrameKind || "normal" : undefined,
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
managerGrantEnabled: Boolean(form.managerGrantEnabled),
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
name: form.name.trim(),
@ -1784,6 +1791,24 @@ function badgeKindFromMetadata(metadataJson) {
return "";
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
if (avatarFrameKind === "level" || avatarFrameKind === "normal") {
return avatarFrameKind;
}
} catch {
return "";
}
return "";
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -1,4 +1,5 @@
import Add from "@mui/icons-material/Add";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import UndoOutlined from "@mui/icons-material/UndoOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
@ -17,9 +18,11 @@ import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/t
import { formatMillis } from "@/shared/utils/time.js";
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
import {
resourceGroupAssetLabels,
resourceGrantStatusFilters,
resourceGrantStatusLabels,
resourceGrantSubjectLabels,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { useResourceGrantListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
@ -27,12 +30,11 @@ import styles from "@/features/resources/resources.module.css";
const grantColumns = [
{
key: "grant",
label: "赠送记录",
width: "minmax(240px, 1.2fr)",
label: "来源",
width: "minmax(220px, 1fr)",
render: (grant) => (
<div className={styles.stack}>
<span className={styles.name}>{grant.grantId || "-"}</span>
<span className={styles.meta}>{grant.commandId || "-"}</span>
<span className={styles.name}>{grantSourceLabel(grant.grantSource)}</span>
</div>
),
},
@ -45,24 +47,19 @@ const grantColumns = [
{
key: "operator",
label: "操作人",
width: "minmax(180px, 1fr)",
width: "minmax(190px, 0.9fr)",
render: (grant) => <GrantOperator grant={grant} />,
},
{
key: "subject",
label: "对象",
width: "minmax(160px, 0.8fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "-"}</span>
<span className={styles.meta}>{grant.grantSubjectId || "-"}</span>
</div>
),
label: "发放方式",
width: "minmax(170px, 0.75fr)",
render: (grant) => <GrantSubject grant={grant} />,
},
{
key: "items",
label: "发放内容",
width: "minmax(180px, 1fr)",
width: "minmax(320px, 1.5fr)",
render: (grant) => <GrantItems grant={grant} />,
},
{
@ -79,8 +76,8 @@ const grantColumns = [
{
key: "reason",
label: "原因",
width: "minmax(160px, 1fr)",
render: (grant) => grant.reason || "-",
width: "minmax(190px, 0.95fr)",
render: (grant) => <GrantReason grant={grant} />,
},
{
key: "time",
@ -143,7 +140,7 @@ export function ResourceGrantListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1460px"
minWidth="1560px"
pagination={
total > 0
? {
@ -347,14 +344,53 @@ function GrantItems({ grant }) {
return "-";
}
return (
<div className={styles.stack}>
{items.slice(0, 2).map((item) => (
<span key={item.grantItemId || `${grant.grantId}-${item.resourceId}`}>
{item.resourceId || "-"} / {item.quantity || 0}
{item.durationMs ? ` / ${formatDuration(item.durationMs)}` : ""}
</span>
<div className={styles.grantMediaScroller}>
{items.map((item) => (
<GrantItemMedia item={item} key={item.grantItemId || `${grant.grantId}-${item.resourceId}`} />
))}
{items.length > 2 ? <span className={styles.meta}>+{items.length - 2}</span> : null}
</div>
);
}
function GrantItemMedia({ item }) {
const snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson);
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
if (walletAssetType) {
const label = resourceGroupAssetLabels[walletAssetType] || walletAssetType;
const amount = snapshotValue(snapshot, "walletAssetAmount", "wallet_asset_amount", "WalletAssetAmount", "quantity");
return <span className={styles.grantAssetPill}>{`${label} ${formatNumber(amount || item.quantity)}`}</span>;
}
const label = grantItemLabel(item, snapshot);
const imageUrl = grantItemImageUrl(snapshot);
return (
<span className={styles.grantMediaCard} title={label}>
<span className={styles.grantMediaThumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
</span>
<span className={styles.grantMediaName}>{label}</span>
</span>
);
}
function GrantSubject({ grant }) {
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const sourceLabel = grantSourceLabel(grant.grantSource);
return (
<div className={styles.stack}>
<span className={styles.name}>{sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`}</span>
<span className={styles.meta}>{subjectMeta(grant)}</span>
</div>
);
}
function GrantReason({ grant }) {
const label = reasonLabel(grant.reason);
const raw = String(grant.reason || "").trim();
return (
<div className={styles.stack}>
<span className={styles.name}>{label}</span>
{raw && raw !== label ? <span className={styles.meta}>{raw}</span> : null}
</div>
);
}
@ -384,26 +420,115 @@ function GrantOperator({ grant }) {
/>
);
}
if (source === "admin") {
const name = operator.name || operator.username || (operatorUserId ? `Admin ${operatorUserId}` : "-");
return (
<div className={styles.stack}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{operator.username || operatorUserId || "-"}</span>
</div>
);
return "-";
}
const grantSourceLabels = {
achievement: "成就奖励",
admin: "后台发放",
agency_opening: "Agency 开通",
cp_weekly_rank: "CP 周榜奖励",
cumulative_recharge_reward: "累充奖励",
first_recharge_reward: "首充奖励",
game_robot_init: "机器人初始化",
growth_level: "成长等级奖励",
manager_center: "经理中心",
registration_reward: "注册奖励",
resource_shop: "道具商店",
room_rocket: "房间火箭",
seven_day_checkin: "七日签到",
weekly_star: "周星奖励",
wheel_reward: "转盘奖励",
};
const grantReasonLabels = {
...grantSourceLabels,
growth_level_registration_badge: "注册成长等级徽章",
"resource shop purchase": "道具商店购买",
};
function grantSourceLabel(source) {
const key = String(source || "").trim();
return grantSourceLabels[key] || key || "-";
}
function reasonLabel(reason) {
const key = String(reason || "").trim();
return grantReasonLabels[key] || key || "-";
}
function subjectMeta(grant) {
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "对象";
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
}
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {
const walletAssetType = snapshotValue(snapshot, "walletAssetType", "wallet_asset_type", "WalletAssetType");
if (walletAssetType) {
const label = resourceGroupAssetLabels[walletAssetType] || walletAssetType;
const amount = snapshotValue(snapshot, "walletAssetAmount", "wallet_asset_amount", "WalletAssetAmount", "quantity");
return `${label} ${formatNumber(amount || item.quantity)}`;
}
if (!operatorUserId) {
return "-";
const resourceName = snapshotValue(snapshot, "name", "Name", "resourceName", "resource_name") || `资源 ${item.resourceId || "-"}`;
const resourceType = snapshotValue(snapshot, "resourceType", "resource_type", "ResourceType");
const typeLabel = resourceTypeLabels[resourceType] || resourceType || "资源";
const quantity = Number(item.quantity || 0);
const parts = [`${resourceName} x${formatNumber(quantity)}`, typeLabel];
if (item.durationMs) {
parts.push(formatDuration(item.durationMs));
}
return (
<div className={styles.stack}>
<span className={styles.name}>{operatorUserId}</span>
<span className={styles.meta}>{source || "-"}</span>
</div>
return parts.join(" · ");
}
function grantItemImageUrl(snapshot) {
return imageURL(
snapshotValue(
snapshot,
"previewUrl",
"preview_url",
"PreviewURL",
"assetUrl",
"asset_url",
"AssetURL",
"animationUrl",
"animation_url",
"AnimationURL",
),
);
}
function parseGrantItemSnapshot(value) {
if (!value || typeof value !== "string") {
return {};
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function snapshotValue(snapshot, ...keys) {
for (const key of keys) {
const value = snapshot?.[key];
if (value !== undefined && value !== null && value !== "") {
return value;
}
}
return "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function imageURL(value) {
const url = String(value || "").trim();
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatDuration(durationMs) {
const days = Number(durationMs || 0) / (24 * 60 * 60 * 1000);
return Number.isInteger(days) ? `${days}` : `${days.toFixed(1)}`;

View File

@ -65,6 +65,91 @@ test("resource grant list hides revoke without permission", () => {
expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument();
});
test("resource grant list renders readable source, reason, and granted items", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
data: {
items: [
grantFixture({
grantId: "rgr_1234567890abcdef",
commandId: "seven_day_checkin_1782448934991_9b39c38",
grantSource: "seven_day_checkin",
grantSubjectId: "18",
grantSubjectType: "resource_group",
items: [
{
grantItemId: 1,
quantity: 100,
resourceId: 0,
resourceSnapshotJson: JSON.stringify({
wallet_asset_type: "COIN",
wallet_asset_amount: 100,
}),
},
{
durationMs: 7 * 24 * 60 * 60 * 1000,
grantItemId: 2,
quantity: 1,
resourceId: 385,
resourceSnapshotJson: JSON.stringify({
Name: "星光头像框",
PreviewURL: "https://media.haiyihy.com/star-frame.png",
ResourceType: "avatar_frame",
}),
},
],
operator: { name: "后台用户", source: "admin", userId: "7" },
reason: "seven_day_checkin",
}),
],
page: 1,
pageSize: 50,
total: 1,
},
}),
);
render(<ResourceGrantListPage />);
expect(screen.getAllByText("七日签到")).toHaveLength(2);
expect(screen.getByText("资源组 · 七日签到")).toBeInTheDocument();
expect(screen.getByText("资源组 ID 18")).toBeInTheDocument();
expect(screen.getByText("金币 100")).toBeInTheDocument();
expect(screen.getByText("星光头像框 x1 · 头像框 · 7天")).toBeInTheDocument();
expect(screen.queryByText("0 / 100")).not.toBeInTheDocument();
expect(screen.queryByText("rgr_1234567890abcdef")).not.toBeInTheDocument();
expect(screen.queryByText("seven_day_checkin_1782448934991_9b39c38")).not.toBeInTheDocument();
expect(screen.queryByText("后台用户")).not.toBeInTheDocument();
expect(document.querySelector('img[src="https://media.haiyihy.com/star-frame.png"]')).toBeInTheDocument();
});
test("resource grant list keeps operator only for user initiated manager grants", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
data: {
items: [
grantFixture({
grantId: "rgr_manager",
grantSource: "manager_center",
operator: {
source: "manager_center",
userId: "328151620022083584",
username: "Manager A",
},
}),
],
page: 1,
pageSize: 50,
total: 1,
},
}),
);
render(<ResourceGrantListPage />);
expect(screen.getByText("Manager A")).toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: {

View File

@ -36,6 +36,8 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
avatarFrameKindLabels,
avatarFrameKindOptions,
badgeKindLabels,
badgeKindOptions,
badgeLevelTrackLabels,
@ -107,9 +109,9 @@ const baseColumns = [
},
{
key: "badgeForm",
label: "徽章属性",
label: "资源属性",
width: "minmax(120px, 0.65fr)",
render: (resource) => badgeFormLabel(resource),
render: (resource) => resourceAttributeLabel(resource),
},
{
key: "status",
@ -1012,9 +1014,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
const resourceType = event.target.value;
setForm((previous) => ({
...previous,
avatarFrameKind:
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal",
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
levelTrack:
resourceType === "badge" ||
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
? previous.levelTrack || ""
: "",
metadataJson:
resourceType === "profile_card"
? previous.metadataJson
@ -1094,6 +1102,46 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
) : null}
</>
) : null}
{form.resourceType === "avatar_frame" ? (
<>
<TextField
disabled={disabled}
label="头像框类型"
required
select
value={form.avatarFrameKind || "normal"}
onChange={(event) =>
setForm({
...form,
avatarFrameKind: event.target.value,
levelTrack: event.target.value === "level" ? form.levelTrack : "",
})
}
>
{avatarFrameKindOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.avatarFrameKind === "level" ? (
<TextField
disabled={disabled}
label="所属等级"
required
select
value={form.levelTrack}
onChange={(event) => setForm({ ...form, levelTrack: event.target.value })}
>
{badgeLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : null}
</>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格">
@ -1297,6 +1345,16 @@ function resourcePriceLabel(resource) {
return "未配置";
}
function resourceAttributeLabel(resource) {
if (resource.resourceType === "badge") {
return badgeFormLabel(resource);
}
if (resource.resourceType === "avatar_frame") {
return avatarFrameKindLabel(resource);
}
return "-";
}
function badgeFormLabel(resource) {
if (resource.resourceType !== "badge") {
return "-";
@ -1309,6 +1367,14 @@ function badgeFormLabel(resource) {
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
}
function avatarFrameKindLabel(resource) {
const kind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal";
const kindLabel = avatarFrameKindLabels[kind] || avatarFrameKindLabels.normal;
const track = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
const trackLabel = kind === "level" && track ? badgeLevelTrackLabels[track] : "";
return [kindLabel, trackLabel].filter(Boolean).join(" · ");
}
function badgeFormFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
@ -1340,6 +1406,21 @@ function badgeKindFromMetadata(metadataJson) {
}
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -100,6 +100,75 @@
white-space: nowrap;
}
.grantMediaScroller {
display: flex;
min-width: 0;
max-width: 100%;
align-items: stretch;
gap: var(--space-2);
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 2px;
scrollbar-width: thin;
}
.grantMediaCard {
display: grid;
width: 72px;
flex: 0 0 72px;
min-width: 0;
justify-items: center;
gap: 4px;
}
.grantMediaThumb {
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.grantMediaThumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.grantMediaName {
width: 100%;
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
font-size: 12px;
font-weight: 650;
line-height: 1.25;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.grantAssetPill {
display: inline-flex;
height: var(--admin-tag-height);
flex: 0 0 auto;
align-items: center;
border: 1px solid var(--admin-tag-border);
border-radius: var(--admin-tag-radius);
background: var(--admin-tag-bg);
color: var(--admin-tag-color);
font-size: var(--admin-tag-font-size);
font-weight: var(--admin-tag-font-weight);
line-height: 1;
padding: 0 var(--admin-tag-padding-x);
white-space: nowrap;
}
.identityGrantLabel {
display: inline-flex;
min-height: var(--control-height);

View File

@ -17,6 +17,7 @@ const resourceTypes = [
];
const walletAssetTypes = ["COIN"];
const resourcePriceTypes = ["coin", "free"];
const avatarFrameKinds = ["normal", "level"];
const badgeForms = ["strip", "tile"];
const badgeKinds = ["normal", "level"];
const badgeLevelTracks = ["wealth", "game", "charm"];
@ -35,6 +36,7 @@ const optionalWalletAssetTypeSchema = z
export const resourceCreateFormSchema = z
.object({
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
badgeForm: z.enum(badgeForms).optional(),
badgeKind: z.enum(badgeKinds).optional(),
enabled: z.boolean(),
@ -65,6 +67,13 @@ export const resourceCreateFormSchema = z
path: ["levelTrack"],
});
}
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
context.addIssue({
code: "custom",
message: "请选择等级头像框所属等级",
path: ["levelTrack"],
});
}
} else {
const amount = Number(value.walletAssetAmount);
if (!Number.isInteger(amount) || amount <= 0) {

View File

@ -80,6 +80,39 @@ describe("resource form schema", () => {
).toThrow(FormValidationError);
});
test("requires level track for level avatar frame resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
avatarFrameKind: "level",
enabled: true,
levelTrack: "game",
managerGrantEnabled: true,
name: "Game Level Frame",
previewUrl: "https://media.haiyihy.com/resource/frame.png",
priceType: "free",
resourceCode: "game_level_frame",
resourceType: "avatar_frame",
walletAssetAmount: "",
});
expect(payload.avatarFrameKind).toBe("level");
expect(payload.levelTrack).toBe("game");
expect(() =>
parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
avatarFrameKind: "level",
enabled: true,
managerGrantEnabled: true,
name: "Game Level Frame",
previewUrl: "https://media.haiyihy.com/resource/frame.png",
priceType: "free",
resourceCode: "game_level_frame",
resourceType: "avatar_frame",
walletAssetAmount: "",
}),
).toThrow(FormValidationError);
});
test("requires explicit confirmation before saving mp4 alpha layout", () => {
const basePayload = {
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",

View File

@ -24,6 +24,7 @@ export function useRoomsPage() {
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
const [ownerUserKeyword, setOwnerUserKeyword] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [contributionSortDirection, setContributionSortDirection] = useState("");
@ -36,12 +37,13 @@ export function useRoomsPage() {
const filters = useMemo(
() => ({
keyword: query,
owner_user_keyword: ownerUserKeyword,
region_id: regionId,
sort_by: contributionSortDirection ? "room_contribution" : "",
sort_direction: contributionSortDirection,
status,
}),
[contributionSortDirection, query, regionId, status],
[contributionSortDirection, ownerUserKeyword, query, regionId, status],
);
const {
data = emptyData,
@ -62,6 +64,11 @@ export function useRoomsPage() {
setPage(1);
};
const changeOwnerUserKeyword = (value) => {
setOwnerUserKeyword(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
@ -79,6 +86,7 @@ export function useRoomsPage() {
const resetFilters = () => {
setQuery("");
setOwnerUserKeyword("");
setStatus("");
setRegionId("");
setPage(1);
@ -184,6 +192,7 @@ export function useRoomsPage() {
activeAction,
activeRoom,
changeQuery,
changeOwnerUserKeyword,
changeRegionId,
changeStatus,
closeAction,
@ -197,6 +206,7 @@ export function useRoomsPage() {
openDetail,
openEdit,
openStatus,
ownerUserKeyword,
page,
query,
regionId,

View File

@ -84,12 +84,21 @@ export function RoomListPage() {
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索房间、房主",
placeholder: "搜索房间 ID、短号、名称、房主靓号",
value: page.query,
onChange: page.changeQuery,
}),
render: (room) => <RoomIdentity room={room} onClick={() => page.openDetail(room)} />,
}
: column.key === "owner"
? {
...column,
filter: createTextColumnFilter({
placeholder: "搜索房主 ID、短号、靓号、昵称",
value: page.ownerUserKeyword,
onChange: page.changeOwnerUserKeyword,
}),
}
: column.key === "status"
? {
...column,

View File

@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { listUsers, updateUserStatus } from "./api";
import { getMoneyScopeCatalog, listUserMoneyScopes, listUsers, replaceUserMoneyScopes, updateUserStatus } from "./api";
afterEach(() => {
setAccessToken("");
@ -37,3 +37,23 @@ test("updateUserStatus sends generated endpoint path and payload", async () => {
expect(init?.method).toBe("PATCH");
expect(JSON.parse(String(init?.body))).toEqual({ status: "locked" });
});
test("money scope APIs use generated endpoint paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { apps: [], items: [], total: 0 } })))
);
await listUserMoneyScopes(9);
await replaceUserMoneyScopes(9, [{ appCode: "lalu", regionId: 2 }]);
await getMoneyScopeCatalog();
const calls = vi.mocked(fetch).mock.calls;
expect(String(calls[0][0])).toContain("/api/v1/users/9/money-scopes");
expect(calls[0][1]?.method).toBe("GET");
expect(String(calls[1][0])).toContain("/api/v1/users/9/money-scopes");
expect(calls[1][1]?.method).toBe("PUT");
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({ scopes: [{ appCode: "lalu", regionId: 2 }] });
expect(String(calls[2][0])).toContain("/api/v1/admin/money/scope");
expect(calls[2][1]?.method).toBe("GET");
});

View File

@ -8,6 +8,7 @@ import type {
PageQuery,
ResetPasswordResultDto,
UserFormPayload,
UserMoneyScopeDto,
UserStatus,
UserUpdatePayload
} from "@/shared/api/types";
@ -63,3 +64,26 @@ export function batchUpdateUserStatus(ids: EntityId[], status: UserStatus): Prom
export function exportUsers(query?: PageQuery): Promise<Response> {
return apiRequest(apiEndpointPath(API_OPERATIONS.exportUsers), { query, raw: true });
}
export interface MoneyScopeCatalogDto {
all?: boolean;
apps?: Array<{ appCode: string; appName?: string }>;
regions?: Array<{ appCode: string; countries?: string[]; name?: string; regionCode?: string; regionId: number }>;
countries?: Array<{ appCode: string; countryCode: string; countryDisplayName?: string; countryName?: string; regionId?: number }>;
scopes?: UserMoneyScopeDto[];
}
export function listUserMoneyScopes(id: EntityId): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }>(apiEndpointPath(API_OPERATIONS.listUserMoneyScopes, { id }));
}
export function replaceUserMoneyScopes(id: EntityId, scopes: UserMoneyScopeDto[]): Promise<{ items: UserMoneyScopeDto[]; total: number }> {
return apiRequest<{ items: UserMoneyScopeDto[]; total: number }, { scopes: UserMoneyScopeDto[] }>(apiEndpointPath(API_OPERATIONS.replaceUserMoneyScopes, { id }), {
body: { scopes },
method: "PUT"
});
}
export function getMoneyScopeCatalog(): Promise<MoneyScopeCatalogDto> {
return apiRequest<MoneyScopeCatalogDto>(apiEndpointPath(API_OPERATIONS.getMoneyScope));
}

View File

@ -6,7 +6,9 @@ import { Button } from "@/shared/ui/Button.jsx";
export function UserFormDrawer({
editingUser,
form,
moneyScopeCatalog,
onClose,
onMoneyScopeChecked,
onRoleChecked,
onSubmit,
open,
@ -34,6 +36,24 @@ export function UserFormDrawer({
) : null}
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">财务范围</div>
<div className="permission-grid permission-grid--money">
{moneyScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
{moneyScopeCatalog?.error ? <span className="permission-check">{moneyScopeCatalog.error}</span> : null}
{!moneyScopeCatalog?.loading && !moneyScopeCatalog?.error
? (moneyScopeCatalog?.apps || []).map((app) => (
<MoneyScopeGroup
app={app}
checkedScopes={form.moneyScopes || []}
key={app.appCode}
onChecked={onMoneyScopeChecked}
regions={(moneyScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
/>
))
: null}
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">角色</div>
<div className="permission-grid">
@ -67,3 +87,29 @@ export function UserFormDrawer({
</Drawer>
);
}
function MoneyScopeGroup({ app, checkedScopes, onChecked, regions }) {
const appCode = app.appCode;
const isChecked = (regionId) =>
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));
return (
<div className="permission-scope-group">
<strong>{app.appName || appCode}</strong>
<label className="permission-check" key={`${appCode}:0`}>
<input checked={isChecked(0)} onChange={(event) => onChecked(appCode, 0, event.target.checked)} type="checkbox" />
<span>全部区域</span>
</label>
{regions.map((region) => (
<label className="permission-check" key={`${appCode}:${region.regionId}`}>
<input
checked={isChecked(region.regionId)}
onChange={(event) => onChecked(appCode, region.regionId, event.target.checked)}
type="checkbox"
/>
<span>{[region.name, region.regionCode].filter(Boolean).join(" · ")}</span>
</label>
))}
</div>
);
}

View File

@ -12,7 +12,10 @@ import {
batchUpdateUserStatus,
createUser,
exportUsers,
getMoneyScopeCatalog,
listUserMoneyScopes,
listUsers,
replaceUserMoneyScopes,
resetUserPassword,
updateUser,
updateUserStatus,
@ -29,6 +32,7 @@ const emptyForm = {
name: "",
password: "",
roleIds: [],
moneyScopes: [],
status: "active",
team: "",
username: "",
@ -44,6 +48,7 @@ export function useUsersPage() {
const [editingUser, setEditingUser] = useState(null);
const [profileUser, setProfileUser] = useState(null);
const [form, setForm] = useState(emptyForm);
const [moneyScopeCatalog, setMoneyScopeCatalog] = useState({ apps: [], countries: [], error: "", loading: false, regions: [] });
const [mergedUsers, setMergedUsers] = useState(emptyUsers);
const abilities = useUserAbilities();
const confirm = useConfirm();
@ -133,10 +138,27 @@ export function useUsersPage() {
setPage(1);
};
const loadMoneyScopeCatalog = useCallback(async () => {
setMoneyScopeCatalog((current) => ({ ...current, error: "", loading: true }));
try {
const catalog = await getMoneyScopeCatalog();
setMoneyScopeCatalog({
apps: Array.isArray(catalog?.apps) ? catalog.apps : [],
countries: Array.isArray(catalog?.countries) ? catalog.countries : [],
error: "",
loading: false,
regions: Array.isArray(catalog?.regions) ? catalog.regions : [],
});
} catch (err) {
setMoneyScopeCatalog({ apps: [], countries: [], error: err.message || "加载财务范围失败", loading: false, regions: [] });
}
}, []);
const openCreate = () => {
setEditingUser(null);
setForm(emptyForm);
setFormOpen(true);
loadMoneyScopeCatalog();
};
const openEdit = (user) => {
@ -146,11 +168,23 @@ export function useUsersPage() {
name: user.name,
password: "",
roleIds: user.roleIds || [],
moneyScopes: [],
status: user.status,
team: user.team || "",
username: user.account,
});
setFormOpen(true);
loadMoneyScopeCatalog();
listUserMoneyScopes(user.id)
.then((data) => {
const scopes = Array.isArray(data?.items) ? data.items : [];
setForm((current) =>
editingUser?.id === user.id || current.username === user.account
? { ...current, moneyScopes: normalizeMoneyScopes(scopes) }
: current,
);
})
.catch((err) => showToast(err.message || "加载财务范围失败", "error"));
};
const closeForm = () => {
@ -171,10 +205,14 @@ export function useUsersPage() {
if (editingUser) {
const payload = parseForm(userUpdateFormSchema, form);
await updateUser(editingUser.id, payload);
await replaceUserMoneyScopes(editingUser.id, normalizeMoneyScopes(form.moneyScopes));
showToast("用户已更新", "success");
} else {
const payload = parseForm(userCreateFormSchema, form);
const result = await createUser(payload);
if (result?.user?.id) {
await replaceUserMoneyScopes(result.user.id, normalizeMoneyScopes(form.moneyScopes));
}
showToast(`用户已创建,初始密码:${result.initialPassword}`, "success");
}
setFormOpen(false);
@ -263,6 +301,28 @@ export function useUsersPage() {
}));
};
const setMoneyScopeChecked = (appCode, regionId, checked) => {
const normalizedAppCode = String(appCode || "").trim();
const normalizedRegionId = Number(regionId || 0);
if (!normalizedAppCode || normalizedRegionId < 0) {
return;
}
setForm((current) => {
const scopes = normalizeMoneyScopes(current.moneyScopes);
const exists = scopes.some((scope) => scope.appCode === normalizedAppCode && Number(scope.regionId) === normalizedRegionId);
if (checked && !exists) {
return { ...current, moneyScopes: [...scopes, { appCode: normalizedAppCode, regionId: normalizedRegionId }] };
}
if (!checked && exists) {
return {
...current,
moneyScopes: scopes.filter((scope) => !(scope.appCode === normalizedAppCode && Number(scope.regionId) === normalizedRegionId)),
};
}
return current;
});
};
return {
abilities,
batchStatus,
@ -277,6 +337,7 @@ export function useUsersPage() {
form,
formOpen,
loading,
moneyScopeCatalog,
navigate,
openCreate,
openEdit,
@ -292,6 +353,7 @@ export function useUsersPage() {
roles,
selectedIds,
setForm,
setMoneyScopeChecked,
setPage,
setRoleChecked,
setSelectedIds,
@ -303,3 +365,23 @@ export function useUsersPage() {
users,
};
}
function normalizeMoneyScopes(scopes = []) {
const seen = new Set();
return (Array.isArray(scopes) ? scopes : [])
.map((scope) => ({
appCode: String(scope.appCode || scope.app_code || "").trim(),
regionId: Number(scope.regionId ?? scope.region_id ?? 0),
}))
.filter((scope) => {
if (!scope.appCode || scope.regionId < 0) {
return false;
}
const key = `${scope.appCode}:${scope.regionId}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

View File

@ -94,7 +94,9 @@ export function UserManagementPage() {
<UserFormDrawer
editingUser={page.editingUser}
form={page.form}
moneyScopeCatalog={page.moneyScopeCatalog}
onClose={page.closeForm}
onMoneyScopeChecked={page.setMoneyScopeChecked}
onRoleChecked={page.setRoleChecked}
onSubmit={page.submitUser}
open={page.formOpen}

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@ export interface paths {
get?: never;
put: operations["updateAchievementDefinition"];
post?: never;
delete?: never;
delete: operations["deleteAchievementDefinition"];
options?: never;
head?: never;
patch?: never;
@ -1860,6 +1860,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/money/scope": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getMoneyScope"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/money/performance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getMoneyPerformance"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/temporary-links": {
parameters: {
query?: never;
@ -1869,7 +1901,7 @@ export interface paths {
};
get: operations["listTemporaryPaymentLinks"];
put?: never;
post?: never;
post: operations["createTemporaryPaymentLink"];
delete?: never;
options?: never;
head?: never;
@ -1924,6 +1956,22 @@ export interface paths {
patch: operations["setThirdPartyPaymentMethodStatus"];
trace?: never;
};
"/admin/payment/third-party-methods/sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["syncThirdPartyPaymentMethods"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/third-party-rates/{method_id}": {
parameters: {
query?: never;
@ -3140,6 +3188,22 @@ export interface paths {
patch: operations["updateUser"];
trace?: never;
};
"/users/{id}/money-scopes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listUserMoneyScopes"];
put: operations["replaceUserMoneyScopes"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/reset-password": {
parameters: {
query?: never;
@ -3541,6 +3605,10 @@ export interface components {
UserInput: {
[key: string]: unknown;
};
UserMoneyScope: {
appCode: string;
regionId: number;
};
};
responses: {
/** @description OK */
@ -3769,6 +3837,13 @@ export interface components {
};
};
};
MoneyScopeRequest: {
content: {
"application/json": {
scopes: components["schemas"]["UserMoneyScope"][];
};
};
};
MenuRequest: {
content: {
"application/json": components["schemas"]["MenuInput"];
@ -3815,6 +3890,19 @@ export interface components {
};
};
};
TemporaryPaymentLinkRequest: {
content: {
"application/json": {
appCode: string;
language?: string;
paymentMethodId: number;
providerCode: string;
regionId: number;
returnUrl?: string;
usdMinorAmount: number;
};
};
};
UserRequest: {
content: {
"application/json": components["schemas"]["UserInput"];
@ -3855,7 +3943,21 @@ export interface operations {
query?: never;
header?: never;
path: {
achievement_id: number;
achievement_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteAchievementDefinition: {
parameters: {
query?: never;
header?: never;
path: {
achievement_id: string;
};
cookie?: never;
};
@ -5898,7 +6000,14 @@ export interface operations {
};
listCoinLedger: {
parameters: {
query?: never;
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
user_keyword?: string;
biz_type?: string;
start_at_ms?: number;
end_at_ms?: number;
};
header?: never;
path?: never;
cookie?: never;
@ -6041,6 +6150,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getMoneyScope: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getMoneyPerformance: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listTemporaryPaymentLinks: {
parameters: {
query?: never;
@ -6053,6 +6186,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
createTemporaryPaymentLink: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: components["requestBodies"]["TemporaryPaymentLinkRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getTemporaryPaymentLink: {
parameters: {
query?: never;
@ -6093,6 +6238,24 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
syncThirdPartyPaymentMethods: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
providerCode?: string;
};
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateThirdPartyPaymentRate: {
parameters: {
query?: never;
@ -6545,7 +6708,16 @@ export interface operations {
};
listRooms: {
parameters: {
query?: never;
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
keyword?: components["parameters"]["Keyword"];
owner_user_keyword?: string;
status?: components["parameters"]["Status"];
region_id?: components["parameters"]["RegionId"];
sort_by?: string;
sort_direction?: string;
};
header?: never;
path?: never;
cookie?: never;
@ -7462,6 +7634,34 @@ export interface operations {
200: components["responses"]["UserResponse"];
};
};
listUserMoneyScopes: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
replaceUserMoneyScopes: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: components["requestBodies"]["MoneyScopeRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
};
};
resetUserPassword: {
parameters: {
query?: never;

View File

@ -21,6 +21,8 @@ export interface PageQuery {
enabled?: boolean;
displayScope?: string;
keyword?: string;
owner_user_keyword?: string;
ownerUserKeyword?: string;
page?: number;
parent_bd_user_id?: EntityId;
parent_leader_user_id?: EntityId;
@ -60,6 +62,12 @@ export interface AdminUserDto {
team?: string;
}
export interface UserMoneyScopeDto {
appCode: string;
regionId: number;
userId?: EntityId;
}
export interface UserFormPayload {
mfaEnabled?: boolean;
name: string;
@ -78,6 +86,7 @@ export interface ResetPasswordResultDto {
export interface CreateUserResultDto {
initialPassword?: string;
user?: AdminUserDto;
}
export interface RoleDto {
@ -477,6 +486,26 @@ export interface ThirdPartyPaymentRateSyncPayload {
markupPercent: number;
}
export interface ThirdPartyPaymentMethodSyncPayload {
providerCode?: string;
}
export interface ThirdPartyPaymentMethodSyncDto {
createdCount: number;
failedCountryCount?: number;
fetchedCountryCount?: number;
issues?: Array<{
code?: string;
countryCode?: string;
currencyCode?: string;
message?: string;
}>;
providerCode?: string;
scannedCountryCount?: number;
skippedCount?: number;
updatedCount: number;
}
export interface TemporaryPaymentLinkDto {
appCode?: string;
audienceType?: string;
@ -913,11 +942,31 @@ export interface PrettyDisplayIDDto {
appCode?: string;
assignedAtMs?: number;
assignedLeaseId?: string;
assignedUser?: {
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: EntityId;
username?: string;
};
assignedUserId?: EntityId;
createdAtMs?: number;
createdByAdminId?: EntityId;
displayUserId: string;
generatedBatchId?: string;
operator?: {
account?: string;
adminId?: EntityId;
avatar?: string;
displayUserId?: string;
name?: string;
source?: string;
type?: string;
userId?: EntityId;
username?: string;
};
pool?: PrettyDisplayIDPoolDto | null;
poolId?: string;
prettyId: string;
@ -926,6 +975,7 @@ export interface PrettyDisplayIDDto {
source: string;
status: string;
updatedAtMs?: number;
updatedByAdminId?: EntityId;
}
export interface PrettyDisplayIDGenerationBatchDto {

View File

@ -1322,6 +1322,24 @@
gap: var(--space-2);
}
.permission-grid--money {
grid-template-columns: 1fr;
}
.permission-scope-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border);
}
.permission-scope-group > strong {
grid-column: 1 / -1;
color: var(--text-primary);
font-size: var(--admin-font-size);
}
.permission-check {
display: flex;
align-items: center;

View File

@ -2,6 +2,48 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
installStoragePolyfill("localStorage");
installStoragePolyfill("sessionStorage");
afterEach(() => {
cleanup();
});
function installStoragePolyfill(name) {
let storage;
try {
storage = window[name];
} catch {
storage = null;
}
if (storage?.getItem && storage?.setItem && storage?.removeItem && storage?.clear) {
return;
}
const store = new Map();
Object.defineProperty(window, name, {
configurable: true,
value: {
clear() {
store.clear();
},
getItem(key) {
const normalizedKey = String(key);
return store.has(normalizedKey) ? store.get(normalizedKey) : null;
},
key(index) {
return Array.from(store.keys())[index] ?? null;
},
get length() {
return store.size;
},
removeItem(key) {
store.delete(String(key));
},
setItem(key, value) {
store.set(String(key), String(value));
},
},
});
}

View File

@ -1,12 +1,39 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
function redirectSubsystemEntry(req, res, next) {
const rawUrl = req.url || "";
const [pathname, query = ""] = rawUrl.split("?");
if (pathname === "/databi" || pathname === "/money") {
res.statusCode = 301;
res.setHeader("Location", `${pathname}/${query ? `?${query}` : ""}`);
res.end();
return;
}
next();
}
function subsystemEntryPlugin() {
return {
name: "hyapp-subsystem-entry",
configureServer(server) {
server.middlewares.use(redirectSubsystemEntry);
},
configurePreviewServer(server) {
server.middlewares.use(redirectSubsystemEntry);
}
};
}
export default defineConfig({
build: {
rollupOptions: {
input: {
admin: new URL("./index.html", import.meta.url).pathname,
databi: new URL("./databi/index.html", import.meta.url).pathname
databi: new URL("./databi/index.html", import.meta.url).pathname,
money: new URL("./money/index.html", import.meta.url).pathname
}
}
},
@ -37,13 +64,23 @@ export default defineConfig({
"react/jsx-runtime"
]
},
plugins: [react()],
plugins: [subsystemEntryPlugin(), react()],
server: {
port: 7001,
proxy: {
"/api": {
target: "http://localhost:13100",
changeOrigin: true
},
"/__api_test__": {
target: "https://api-test.global-interaction.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/__api_test__/, "")
},
"/__api_local__": {
target: "http://127.0.0.1:13000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/__api_local__/, "")
}
}
},