Compare commits
7 Commits
2125f81baf
...
3e8d4b4aea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8d4b4aea | ||
|
|
f059b0c60c | ||
|
|
7858530fc8 | ||
|
|
63d4ffbacd | ||
|
|
8c9f5d35b2 | ||
|
|
0112303b88 | ||
|
|
c33fb469d9 |
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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("真人房机器人送礼指标");
|
||||
|
||||
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
72
databi/src/charts/options/createReportRevenueOption.js
Normal 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
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@ -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="数据大屏切换">
|
||||
|
||||
537
databi/src/components/ReportOverview.jsx
Normal file
537
databi/src/components/ReportOverview.jsx
Normal 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));
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -5,4 +5,5 @@
|
||||
@import "./panels.css";
|
||||
@import "./charts.css";
|
||||
@import "./tables.css";
|
||||
@import "./report.css";
|
||||
@import "./responsive.css";
|
||||
|
||||
@ -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 {
|
||||
|
||||
713
databi/src/styles/report.css
Normal file
713
databi/src/styles/report.css
Normal 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
12
money/index.html
Normal 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
319
money/src/MoneyApp.jsx
Normal 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
99
money/src/api.js
Normal 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");
|
||||
}
|
||||
}
|
||||
43
money/src/charts/EChart.jsx
Normal file
43
money/src/charts/EChart.jsx
Normal 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} />;
|
||||
}
|
||||
101
money/src/components/CountryChannelPanel.jsx
Normal file
101
money/src/components/CountryChannelPanel.jsx
Normal 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;
|
||||
}
|
||||
90
money/src/components/MoneyHeader.jsx
Normal file
90
money/src/components/MoneyHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
money/src/components/MoneyKpis.jsx
Normal file
23
money/src/components/MoneyKpis.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
money/src/components/MoneyOverview.jsx
Normal file
85
money/src/components/MoneyOverview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
money/src/components/MoneySidebar.jsx
Normal file
101
money/src/components/MoneySidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
money/src/components/MoneyTrendPanel.jsx
Normal file
101
money/src/components/MoneyTrendPanel.jsx
Normal 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"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
104
money/src/components/OperatorPerformancePanel.jsx
Normal file
104
money/src/components/OperatorPerformancePanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
560
money/src/components/PaymentLinkPanel.jsx
Normal file
560
money/src/components/PaymentLinkPanel.jsx
Normal 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");
|
||||
}
|
||||
}
|
||||
136
money/src/components/PerformanceTable.jsx
Normal file
136
money/src/components/PerformanceTable.jsx
Normal 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
16
money/src/data.js
Normal 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
1
money/src/format.js
Normal file
@ -0,0 +1 @@
|
||||
export { formatDateTime, formatNumber, formatPercent, formatUsd } from "./model/format.js";
|
||||
10
money/src/main.jsx
Normal file
10
money/src/main.jsx
Normal 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
306
money/src/masterDataApi.js
Normal 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;
|
||||
}
|
||||
112
money/src/masterDataApi.test.js
Normal file
112
money/src/masterDataApi.test.js
Normal 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
424
money/src/model/data.js
Normal 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())}`;
|
||||
}
|
||||
56
money/src/model/data.test.js
Normal file
56
money/src/model/data.test.js
Normal 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
32
money/src/model/format.js
Normal 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(),
|
||||
)}`;
|
||||
}
|
||||
55
money/src/paymentLinksApi.js
Normal file
55
money/src/paymentLinksApi.js
Normal 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
1704
money/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
117
money/src/temporaryPaymentApi.js
Normal file
117
money/src/temporaryPaymentApi.js
Normal 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 || "-";
|
||||
}
|
||||
35
money/src/temporaryPaymentApi.test.js
Normal file
35
money/src/temporaryPaymentApi.test.js
Normal 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("已入账");
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 || "-"}`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
169
src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx
Normal file
169
src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
85
src/features/level-config/hooks/useLevelConfigPage.test.jsx
Normal file
85
src/features/level-config/hooks/useLevelConfigPage.test.jsx
Normal 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]);
|
||||
});
|
||||
@ -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 "";
|
||||
|
||||
@ -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, "排序必须是整数");
|
||||
});
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 || "-";
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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) => {
|
||||
// 支付方式同步只开放给 V5Pay;MiFaPay 暂无可查询当前商户已开通方式的后端接口。
|
||||
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}`;
|
||||
}
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", "游戏等级"],
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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)}天`;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
210
src/shared/api/generated/schema.d.ts
vendored
210
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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__/, "")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user