Compare commits
No commits in common. "64cfa472b8f959887779e36eccf14a437d5266e0" and "103b70f5e5f6e2de1431ba2892405ca2766b87ed" have entirely different histories.
64cfa472b8
...
103b70f5e5
@ -1 +0,0 @@
|
|||||||
VITE_API_BASE_URL=/api
|
|
||||||
@ -4892,10 +4892,10 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"ownerUserId": {
|
"ownerUserId": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"parentBdUserId": {
|
"parentBdUserId": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"regionId": {
|
"regionId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@ -4919,7 +4919,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"parentLeaderUserId": {
|
"parentLeaderUserId": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"regionId": {
|
"regionId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@ -4934,7 +4934,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -4991,7 +4991,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
import { fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
|
||||||
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||||
@ -8,7 +8,6 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
|||||||
import { MetricCard } from "./components/MetricCard.jsx";
|
import { MetricCard } from "./components/MetricCard.jsx";
|
||||||
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||||
|
|
||||||
@ -26,22 +25,15 @@ export function DatabiApp() {
|
|||||||
const [countryId, setCountryId] = useState(0);
|
const [countryId, setCountryId] = useState(0);
|
||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
const [activeScreen, setActiveScreen] = useState("overview");
|
|
||||||
const [gameId, setGameId] = useState("all");
|
|
||||||
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
||||||
const [filtersLoading, setFiltersLoading] = useState(false);
|
const [filtersLoading, setFiltersLoading] = useState(false);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [selfGameOverview, setSelfGameOverview] = useState(null);
|
|
||||||
const [selfGameLoading, setSelfGameLoading] = useState(false);
|
|
||||||
const [selfGameError, setSelfGameError] = useState("");
|
|
||||||
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
||||||
const [trendModalItem, setTrendModalItem] = useState(null);
|
const [trendModalItem, setTrendModalItem] = useState(null);
|
||||||
const overviewRequestIdRef = useRef(0);
|
const overviewRequestIdRef = useRef(0);
|
||||||
const selfGameRequestIdRef = useRef(0);
|
|
||||||
const overviewRef = useRef(null);
|
const overviewRef = useRef(null);
|
||||||
const selfGameOverviewRef = useRef(null);
|
|
||||||
const scopedCountries = useMemo(() => {
|
const scopedCountries = useMemo(() => {
|
||||||
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
||||||
}, [filterOptions.countryOptions, regionId]);
|
}, [filterOptions.countryOptions, regionId]);
|
||||||
@ -86,10 +78,6 @@ export function DatabiApp() {
|
|||||||
overviewRef.current = overview;
|
overviewRef.current = overview;
|
||||||
}, [overview]);
|
}, [overview]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selfGameOverviewRef.current = selfGameOverview;
|
|
||||||
}, [selfGameOverview]);
|
|
||||||
|
|
||||||
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
||||||
const requestId = overviewRequestIdRef.current + 1;
|
const requestId = overviewRequestIdRef.current + 1;
|
||||||
overviewRequestIdRef.current = requestId;
|
overviewRequestIdRef.current = requestId;
|
||||||
@ -130,63 +118,19 @@ export function DatabiApp() {
|
|||||||
}
|
}
|
||||||
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
||||||
|
|
||||||
const loadSelfGameOverview = useCallback(async ({ preserveData = false } = {}) => {
|
|
||||||
const requestId = selfGameRequestIdRef.current + 1;
|
|
||||||
selfGameRequestIdRef.current = requestId;
|
|
||||||
const keepCurrentOverview = preserveData && selfGameOverviewRef.current;
|
|
||||||
setSelfGameLoading(true);
|
|
||||||
setSelfGameError("");
|
|
||||||
if (!keepCurrentOverview) {
|
|
||||||
setSelfGameOverview(null);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await fetchSelfGameStatisticsOverview({
|
|
||||||
appCode,
|
|
||||||
countryId,
|
|
||||||
endMs: rangeEndMs(range, timeZone),
|
|
||||||
gameId,
|
|
||||||
regionId,
|
|
||||||
startMs: rangeStartMs(range, timeZone)
|
|
||||||
});
|
|
||||||
if (requestId === selfGameRequestIdRef.current) {
|
|
||||||
setSelfGameOverview(data || {});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (requestId === selfGameRequestIdRef.current) {
|
|
||||||
if (!keepCurrentOverview) {
|
|
||||||
setSelfGameOverview(null);
|
|
||||||
}
|
|
||||||
setSelfGameError(err.message || "请求失败");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (requestId === selfGameRequestIdRef.current) {
|
|
||||||
setSelfGameLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [appCode, countryId, gameId, range, regionId, timeZone]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadFilterOptions();
|
void loadFilterOptions();
|
||||||
}, [loadFilterOptions]);
|
}, [loadFilterOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeScreen === "selfGames") {
|
|
||||||
void loadSelfGameOverview();
|
|
||||||
const timer = window.setInterval(() => void loadSelfGameOverview({ preserveData: true }), 60_000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}
|
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [activeScreen, loadOverview, loadSelfGameOverview]);
|
}, [loadOverview]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
if (activeScreen === "selfGames") {
|
|
||||||
void loadSelfGameOverview({ preserveData: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadOverview({ preserveData: true });
|
void loadOverview({ preserveData: true });
|
||||||
}, [activeScreen, loadOverview, loadSelfGameOverview]);
|
}, [loadOverview]);
|
||||||
|
|
||||||
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
||||||
const showSkeleton = loading && !overview;
|
const showSkeleton = loading && !overview;
|
||||||
@ -196,22 +140,20 @@ export function DatabiApp() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="databi-shell">
|
<main className="databi-shell">
|
||||||
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
|
<section className="databi-screen">
|
||||||
<DatabiHeader
|
<DatabiHeader
|
||||||
activeScreen={activeScreen}
|
|
||||||
appCode={appCode}
|
appCode={appCode}
|
||||||
appOptions={filterOptions.appOptions}
|
appOptions={filterOptions.appOptions}
|
||||||
countryOptions={filterOptions.countryOptions}
|
countryOptions={filterOptions.countryOptions}
|
||||||
countryId={countryId}
|
countryId={countryId}
|
||||||
error={activeScreen === "selfGames" ? selfGameError : error}
|
error={error}
|
||||||
filtersLoading={filtersLoading}
|
filtersLoading={filtersLoading}
|
||||||
loading={activeScreen === "selfGames" ? selfGameLoading : loading}
|
loading={loading}
|
||||||
onAppChange={handleAppChange}
|
onAppChange={handleAppChange}
|
||||||
onCountryChange={setCountryId}
|
onCountryChange={setCountryId}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
onRegionChange={handleRegionChange}
|
onRegionChange={handleRegionChange}
|
||||||
onScreenChange={setActiveScreen}
|
|
||||||
onTimeZoneChange={handleTimeZoneChange}
|
onTimeZoneChange={handleTimeZoneChange}
|
||||||
range={range}
|
range={range}
|
||||||
regionId={regionId}
|
regionId={regionId}
|
||||||
@ -221,43 +163,33 @@ export function DatabiApp() {
|
|||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeScreen === "selfGames" ? (
|
<section className="metric-grid" aria-label="核心指标">
|
||||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
{model.kpis.map((item) => (
|
||||||
) : (
|
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
||||||
<>
|
))}
|
||||||
<section className="metric-grid" aria-label="核心指标">
|
</section>
|
||||||
{model.kpis.map((item) => (
|
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="business-metric-grid" aria-label="业务指标">
|
<section className="business-metric-grid" aria-label="业务指标">
|
||||||
{model.businessKpis.map((item) => (
|
{model.businessKpis.map((item) => (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
loading={showSkeleton}
|
loading={showSkeleton}
|
||||||
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="databi-grid databi-grid--top">
|
<section className="databi-grid databi-grid--top">
|
||||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||||
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
||||||
</section>
|
</section>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeScreen === "overview" ? (
|
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
||||||
<>
|
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
||||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
||||||
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
|
||||||
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
import { fetchFilterOptions, fetchStatisticsOverview } from "./api.js";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
fetchFilterOptions: vi.fn(),
|
fetchFilterOptions: vi.fn(),
|
||||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
|
||||||
fetchStatisticsOverview: vi.fn(),
|
fetchStatisticsOverview: vi.fn(),
|
||||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
||||||
@ -23,7 +22,6 @@ beforeEach(() => {
|
|||||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
});
|
});
|
||||||
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -53,27 +51,6 @@ test("keeps current data visible during scheduled refresh", async () => {
|
|||||||
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
|
||||||
events: [{ event_name: "page_open", event_count: 8, user_count: 5 }],
|
|
||||||
match_totals: { created_matches: 3, matched_matches: 2, settled_matches: 1 },
|
|
||||||
retention: { cohort_users: 5, day1_rate: 0.2, day7_rate: 0.1 }
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DatabiApp />);
|
|
||||||
|
|
||||||
await flushEffects();
|
|
||||||
await act(async () => {
|
|
||||||
screen.getByRole("button", { name: "自研游戏" }).click();
|
|
||||||
});
|
|
||||||
await flushEffects();
|
|
||||||
|
|
||||||
expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all" }));
|
|
||||||
expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy();
|
|
||||||
expect(screen.getByText("创建局")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function flushEffects() {
|
async function flushEffects() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let index = 0; index < 10; index += 1) {
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
|||||||
@ -40,27 +40,6 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
|
|||||||
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs }) {
|
|
||||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
|
||||||
if (startMs) {
|
|
||||||
query.start_ms = String(startMs);
|
|
||||||
}
|
|
||||||
if (endMs) {
|
|
||||||
query.end_ms = String(endMs);
|
|
||||||
}
|
|
||||||
if (countryId) {
|
|
||||||
query.country_id = String(countryId);
|
|
||||||
}
|
|
||||||
if (regionId && regionId !== "all") {
|
|
||||||
query.region_id = String(regionId);
|
|
||||||
}
|
|
||||||
if (gameId && gameId !== "all") {
|
|
||||||
query.game_id = String(gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchDatabiData("/v1/statistics/self-games/overview", { appCode, query });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFilterOptions({ appCode } = {}) {
|
export async function fetchFilterOptions({ appCode } = {}) {
|
||||||
const [apps, regions, countries] = await Promise.all([
|
const [apps, regions, countries] = await Promise.all([
|
||||||
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
||||||
|
|||||||
@ -19,15 +19,11 @@ const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
|
|||||||
const seconds = minutes;
|
const seconds = minutes;
|
||||||
|
|
||||||
export function DatabiHeader({
|
export function DatabiHeader({
|
||||||
activeScreen = "overview",
|
|
||||||
appCode,
|
appCode,
|
||||||
appOptions = [],
|
appOptions = [],
|
||||||
countryId,
|
countryId,
|
||||||
countryOptions = defaultCountryOptions,
|
countryOptions = defaultCountryOptions,
|
||||||
error = "",
|
|
||||||
filtersLoading = false,
|
filtersLoading = false,
|
||||||
loading = false,
|
|
||||||
onScreenChange,
|
|
||||||
onAppChange,
|
onAppChange,
|
||||||
onCountryChange,
|
onCountryChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
@ -83,14 +79,6 @@ export function DatabiHeader({
|
|||||||
<h1>App Data</h1>
|
<h1>App Data</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="screen-switch" aria-label="数据大屏切换">
|
|
||||||
<button className={activeScreen === "overview" ? "is-active" : ""} onClick={() => onScreenChange?.("overview")} type="button">
|
|
||||||
运营总览
|
|
||||||
</button>
|
|
||||||
<button className={activeScreen === "selfGames" ? "is-active" : ""} onClick={() => onScreenChange?.("selfGames")} type="button">
|
|
||||||
自研游戏
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
className="filter-control--timezone"
|
className="filter-control--timezone"
|
||||||
@ -145,10 +133,7 @@ export function DatabiHeader({
|
|||||||
value={countryId}
|
value={countryId}
|
||||||
valueLabel={countryLabel}
|
valueLabel={countryLabel}
|
||||||
/>
|
/>
|
||||||
<span className={error ? "live-dot live-dot--warn" : "live-dot"} title={error || ""}>
|
<span className="live-dot">实时</span>
|
||||||
{error ? "异常" : loading ? "刷新中" : "实时"}
|
|
||||||
</span>
|
|
||||||
{error ? <span className="header-error">{error}</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@ -158,11 +143,11 @@ function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSe
|
|||||||
return (
|
return (
|
||||||
<div className={["filter-control", className].filter(Boolean).join(" ")}>
|
<div className={["filter-control", className].filter(Boolean).join(" ")}>
|
||||||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||||||
<span className="filter-label">
|
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
<span className="filter-copy">
|
||||||
<span>{label}</span>
|
<small>{label}</small>
|
||||||
|
<b>{valueLabel}</b>
|
||||||
</span>
|
</span>
|
||||||
<span className="filter-value">{valueLabel}</span>
|
|
||||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
@ -201,11 +186,11 @@ function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect,
|
|||||||
return (
|
return (
|
||||||
<div className="filter-control filter-control--country">
|
<div className="filter-control filter-control--country">
|
||||||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||||||
<span className="filter-label">
|
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
<span className="filter-copy">
|
||||||
<span>{label}</span>
|
<small>{label}</small>
|
||||||
|
<b>{valueLabel}</b>
|
||||||
</span>
|
</span>
|
||||||
<span className="filter-value">{valueLabel}</span>
|
|
||||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
@ -301,14 +286,14 @@ function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone })
|
|||||||
return (
|
return (
|
||||||
<div className="filter-control filter-control--range">
|
<div className="filter-control filter-control--range">
|
||||||
<button aria-expanded={isOpen} aria-haspopup="listbox" className="date-range-trigger" onClick={onToggle} type="button">
|
<button aria-expanded={isOpen} aria-haspopup="listbox" className="date-range-trigger" onClick={onToggle} type="button">
|
||||||
<span className="date-range-label">
|
<span className="date-range-icon"><CalendarMonthOutlined fontSize="small" /></span>
|
||||||
<CalendarMonthOutlined fontSize="inherit" />
|
<span className="date-range-copy">
|
||||||
<span>{activeOption?.label || "时间范围"}</span>
|
<span className="date-range-label">{activeOption?.label || "时间范围"}</span>
|
||||||
</span>
|
<span className="date-range-values">
|
||||||
<span className="date-range-values">
|
<b>{range.start}</b>
|
||||||
<b>{range.start}</b>
|
<i>~</i>
|
||||||
<i>~</i>
|
<b>{range.end}</b>
|
||||||
<b>{range.end}</b>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
|
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Panel } from "./Panel.jsx";
|
|||||||
|
|
||||||
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
|
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
|
||||||
return (
|
return (
|
||||||
<Panel title="付费转化漏斗">
|
<Panel title="ARPU 转化漏斗">
|
||||||
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
|
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
|
||||||
<div className="funnel-stack">
|
<div className="funnel-stack">
|
||||||
{loading
|
{loading
|
||||||
|
|||||||
@ -1,438 +0,0 @@
|
|||||||
import { MetricCard } from "./MetricCard.jsx";
|
|
||||||
import { Panel } from "./Panel.jsx";
|
|
||||||
import { formatCoin, formatNumber, formatPercent } from "../utils/format.js";
|
|
||||||
import { numberValue } from "../utils/number.js";
|
|
||||||
|
|
||||||
const gameOptions = [
|
|
||||||
{ label: "全部", value: "all" },
|
|
||||||
{ label: "Dice", value: "dice" },
|
|
||||||
{ label: "Rock", value: "rock" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const eventLabels = {
|
|
||||||
page_open: "页面打开",
|
|
||||||
config_load_success: "配置成功",
|
|
||||||
config_load_failed: "配置失败",
|
|
||||||
gesture_select: "选择手势",
|
|
||||||
match_api: "匹配接口",
|
|
||||||
match_cancel: "取消匹配",
|
|
||||||
stake_selected: "选择档位",
|
|
||||||
stake_select: "选择档位",
|
|
||||||
match_click: "点击匹配",
|
|
||||||
match_success: "匹配成功",
|
|
||||||
animation_start: "进入开奖",
|
|
||||||
preload_complete: "预加载完成",
|
|
||||||
reveal_enter: "进入开奖",
|
|
||||||
roll_api: "开奖接口",
|
|
||||||
settlement_show: "结算展示",
|
|
||||||
play_again: "再来一局",
|
|
||||||
play_again_click: "再来一局",
|
|
||||||
exit_click: "退出"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SelfGameScreen({ gameId = "all", loading = false, onGameChange, overview }) {
|
|
||||||
const model = buildSelfGameModel(overview);
|
|
||||||
const showSkeleton = loading && !overview;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="self-game-screen" aria-label="自研游戏统计">
|
|
||||||
<div className="self-game-toolbar">
|
|
||||||
<div className="self-game-title">
|
|
||||||
<strong>自研游戏</strong>
|
|
||||||
<span>Dice / Rock H5 聚合统计</span>
|
|
||||||
</div>
|
|
||||||
<div className="game-switch" role="tablist" aria-label="游戏筛选">
|
|
||||||
{gameOptions.map((item) => (
|
|
||||||
<button className={gameId === item.value ? "is-active" : ""} key={item.value} onClick={() => onGameChange?.(item.value)} type="button">
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="self-game-kpi-grid" aria-label="自研游戏核心指标">
|
|
||||||
{model.kpis.map((item) => (
|
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="self-game-panel-grid self-game-panel-grid--top">
|
|
||||||
<FunnelTable funnel={model.funnel} loading={showSkeleton} />
|
|
||||||
<StakeTable items={model.stakes} loading={showSkeleton} />
|
|
||||||
<PoolTable items={model.pools} loading={showSkeleton} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="self-game-panel-grid">
|
|
||||||
<ParticipantTable items={model.participants} loading={showSkeleton} />
|
|
||||||
<WinLossTable summary={model.winLoss} loading={showSkeleton} />
|
|
||||||
<RiskTable items={model.risks} loading={showSkeleton} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="self-game-panel-grid self-game-panel-grid--single">
|
|
||||||
<MetricDefinitionList items={model.definitions} loading={showSkeleton} />
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSelfGameModel(overview) {
|
|
||||||
const events = eventIndex(overview?.events);
|
|
||||||
const totals = overview?.match_totals || {};
|
|
||||||
const retention = overview?.retention || {};
|
|
||||||
const totalStake = numberValue(totals.user_stake_coin) + numberValue(totals.robot_stake_coin);
|
|
||||||
const participants = Array.isArray(overview?.participant_distribution) ? overview.participant_distribution : [];
|
|
||||||
const pools = Array.isArray(overview?.pool_stats) ? overview.pool_stats : [];
|
|
||||||
const userResult = realUserResultCounts(participants, totals);
|
|
||||||
const userNetWin = numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin);
|
|
||||||
const poolBalance = latestPoolBalance(pools);
|
|
||||||
const settlementEvent = events.get("settlement_show");
|
|
||||||
const matchClickEvent = events.get("match_click");
|
|
||||||
const matchSuccessEvent = events.get("match_success");
|
|
||||||
const pageOpenEvent = events.get("page_open");
|
|
||||||
const winLoss = {
|
|
||||||
countNote: userResult.countNote,
|
|
||||||
drawCount: userResult.draw,
|
|
||||||
loserCount: userResult.lose,
|
|
||||||
payoutCoin: numberValue(totals.payout_coin),
|
|
||||||
refundCoin: numberValue(totals.refund_coin),
|
|
||||||
userNetWin,
|
|
||||||
userStakeCoin: numberValue(totals.user_stake_coin),
|
|
||||||
winnerCount: userResult.win
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
metric("游戏 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "进入 dice、rock H5 的次数和人数"),
|
|
||||||
metric("开局人数", `点击 ${formatNumber(matchClickEvent.user_count)} / 成功 ${formatNumber(matchSuccessEvent.user_count)}`, `${formatNumber(matchClickEvent.event_count)} 次点击`),
|
|
||||||
metric("创建局", formatNumber(totals.created_matches), "服务端创建的对局数"),
|
|
||||||
metric("匹配成功局", formatNumber(totals.matched_matches), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`),
|
|
||||||
metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`),
|
|
||||||
metric("已结算局", formatNumber(totals.settled_matches), "已经完成派奖或退款的局"),
|
|
||||||
metric("取消 / 失败局", `${formatNumber(totals.canceled_matches)} / ${formatNumber(totals.failed_matches)}`, `${formatPercent(totals.cancel_rate)} / ${formatPercent(totals.fail_rate)}`),
|
|
||||||
metric("参与人次", formatNumber(numberValue(totals.human_participants) + numberValue(totals.robot_participants)), `真人 ${formatNumber(totals.human_participants)} / 机器人 ${formatNumber(totals.robot_participants)}`),
|
|
||||||
metric("总下注金币", formatCoin(totalStake), `真人 ${formatCoin(totals.user_stake_coin)} / 机器人 ${formatCoin(totals.robot_stake_coin)}`),
|
|
||||||
metric("派奖金额", formatCoin(totals.payout_coin), `退款 ${formatCoin(totals.refund_coin)}`),
|
|
||||||
metric("平台抽水", formatCoin(totals.platform_profit_coin), formatPercent(totals.platform_profit_rate)),
|
|
||||||
metric("奖池变化", formatCoin(totals.pool_delta_coin), `余额 ${formatCoin(poolBalance)} / 强制 ${formatNumber(totals.forced_player_lose_matches)} 次`),
|
|
||||||
metric("用户输赢", `${formatNumber(winLoss.winnerCount)} / ${formatNumber(winLoss.loserCount)} / ${formatNumber(winLoss.drawCount)}`, `真实用户净输赢 ${formatCoin(winLoss.userNetWin)}`),
|
|
||||||
metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)} 人`)
|
|
||||||
],
|
|
||||||
definitions: overview?.metric_definitions || [],
|
|
||||||
funnel: overview?.funnel || [],
|
|
||||||
participants,
|
|
||||||
pools,
|
|
||||||
risks: overview?.user_risk || [],
|
|
||||||
stakes: overview?.stake_breakdown || [],
|
|
||||||
winLoss
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventIndex(items) {
|
|
||||||
const index = new Map();
|
|
||||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
|
||||||
const name = item.event_name || "";
|
|
||||||
const current = index.get(name) || emptyEvent();
|
|
||||||
index.set(name, {
|
|
||||||
event_count: numberValue(current.event_count) + numberValue(item.event_count),
|
|
||||||
user_count: numberValue(current.user_count) + numberValue(item.user_count)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
get(name) {
|
|
||||||
return index.get(name) || emptyEvent();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyEvent() {
|
|
||||||
return { event_count: 0, user_count: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(label, value, caption) {
|
|
||||||
return { caption, label, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
function FunnelTable({ funnel, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="转化漏斗">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无漏斗数据"
|
|
||||||
loading={loading}
|
|
||||||
rows={funnel}
|
|
||||||
columns={[
|
|
||||||
{ key: "event_name", label: "节点", render: (row) => eventLabels[row.event_name] || row.event_name || "--" },
|
|
||||||
{ key: "user_count", label: "UV", render: (row) => formatNumber(row.user_count) },
|
|
||||||
{ key: "event_count", label: "次数", render: (row) => formatNumber(row.event_count) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StakeTable({ items, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="下注档位">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无档位数据"
|
|
||||||
loading={loading}
|
|
||||||
rows={items}
|
|
||||||
wide
|
|
||||||
columns={[
|
|
||||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
|
||||||
{ key: "created_matches", label: "创建局", render: (row) => formatNumber(row.created_matches) },
|
|
||||||
{ key: "matched_matches", label: "匹配局", render: (row) => formatNumber(row.matched_matches) },
|
|
||||||
{ key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) },
|
|
||||||
{ key: "user_stake_coin", label: "真人下注", render: (row) => formatCoin(row.user_stake_coin) },
|
|
||||||
{ key: "robot_stake_coin", label: "机器人下注", render: (row) => formatCoin(row.robot_stake_coin) },
|
|
||||||
{ key: "stake", label: "总下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) },
|
|
||||||
{ key: "payout_coin", label: "派奖", render: (row) => formatCoin(row.payout_coin) },
|
|
||||||
{ key: "profit", label: "盈利", render: (row) => formatCoin(row.platform_profit_coin) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PoolTable({ items, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="奖池流水">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无奖池流水"
|
|
||||||
loading={loading}
|
|
||||||
rows={items}
|
|
||||||
columns={[
|
|
||||||
{ key: "direction", label: "方向", render: (row) => directionLabel(row.direction) },
|
|
||||||
{ key: "reason", label: "原因", render: (row) => row.reason || "--" },
|
|
||||||
{ key: "flow", label: "入 / 出", render: (row) => `${formatCoin(row.in_coin)} / ${formatCoin(row.out_coin)}` },
|
|
||||||
{ key: "balance_after_coin", label: "余额", render: (row) => formatCoin(row.balance_after_coin) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticipantTable({ items, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="玩法分布">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无玩法数据"
|
|
||||||
loading={loading}
|
|
||||||
rows={items}
|
|
||||||
wide
|
|
||||||
columns={[
|
|
||||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
|
||||||
{ key: "type", label: "对象", render: (row) => participantLabel(row.participant_type) },
|
|
||||||
{ key: "play", label: "手势 / 点数", render: (row) => playLabel(row) },
|
|
||||||
{ key: "result", label: "结果", render: (row) => resultLabel(row.result) },
|
|
||||||
{ key: "count", label: "人次", render: (row) => formatNumber(row.participant_count) },
|
|
||||||
{ key: "net_win_coin", label: "净输赢", render: (row) => formatCoin(row.net_win_coin) },
|
|
||||||
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WinLossTable({ loading, summary }) {
|
|
||||||
const rows = [
|
|
||||||
{ metric: "真实用户赢家数", value: summary.winnerCount, note: summary.countNote },
|
|
||||||
{ metric: "真实用户输家数", value: summary.loserCount, note: summary.countNote },
|
|
||||||
{ metric: "真实用户平局数", value: summary.drawCount, note: summary.countNote },
|
|
||||||
{ metric: "真实用户下注", value: summary.userStakeCoin, note: "match_totals.user_stake_coin", type: "coin" },
|
|
||||||
{ metric: "真实用户派奖", value: summary.payoutCoin, note: "match_totals.payout_coin", type: "coin" },
|
|
||||||
{ metric: "真实用户退款", value: summary.refundCoin, note: "match_totals.refund_coin", type: "coin" },
|
|
||||||
{ metric: "真实用户净输赢", value: summary.userNetWin, note: "派奖 + 退款 - 真实用户下注", type: "coin" }
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="用户输赢">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无输赢数据"
|
|
||||||
loading={loading}
|
|
||||||
rows={rows}
|
|
||||||
columns={[
|
|
||||||
{ key: "metric", label: "指标", render: (row) => row.metric },
|
|
||||||
{ key: "value", label: "数值", render: (row) => (row.type === "coin" ? formatCoin(row.value) : formatNumber(row.value)) },
|
|
||||||
{ key: "note", label: "口径", render: (row) => row.note }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskTable({ items, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="高风险用户">
|
|
||||||
<DataTable
|
|
||||||
emptyText="暂无风险用户"
|
|
||||||
loading={loading}
|
|
||||||
rows={items}
|
|
||||||
columns={[
|
|
||||||
{ key: "user_id", label: "用户", render: (row) => <RiskUserCell row={row} /> },
|
|
||||||
{ key: "match_count", label: "局数", render: (row) => formatNumber(row.match_count) },
|
|
||||||
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) },
|
|
||||||
{ key: "net_win_coin", label: "净赢", render: (row) => formatCoin(row.net_win_coin) },
|
|
||||||
{ key: "cancel_rate", label: "取消率", render: (row) => formatPercent(row.cancel_rate) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskUserCell({ row }) {
|
|
||||||
const nickname = row.nickname || row.username || "";
|
|
||||||
const shortID = row.short_id || row.display_user_id || "";
|
|
||||||
const userID = row.user_id ? String(row.user_id) : "";
|
|
||||||
const avatar = row.avatar || "";
|
|
||||||
const primary = nickname || shortID || userID || "--";
|
|
||||||
const secondary = shortID ? `ID ${shortID}` : userID ? `UID ${userID}` : "";
|
|
||||||
return (
|
|
||||||
<div className="risk-user-cell">
|
|
||||||
{avatar ? <img alt="" className="risk-user-avatar" src={avatar} /> : <span className="risk-user-avatar risk-user-avatar--empty">{primary.slice(0, 1).toUpperCase()}</span>}
|
|
||||||
<span className="risk-user-copy">
|
|
||||||
<strong>{primary}</strong>
|
|
||||||
{secondary ? <small>{secondary}</small> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricDefinitionList({ items, loading }) {
|
|
||||||
return (
|
|
||||||
<Panel className="panel--self-game" title="口径">
|
|
||||||
{loading ? <div className="panel-empty">加载中...</div> : null}
|
|
||||||
{!loading && !items.length ? <div className="panel-empty">暂无口径说明</div> : null}
|
|
||||||
{!loading && items.length ? (
|
|
||||||
<div className="definition-list">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div className="definition-row" key={item.metric}>
|
|
||||||
<strong>{item.metric}</strong>
|
|
||||||
<span>{item.definition}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataTable({ columns, emptyText, loading, rows, wide = false }) {
|
|
||||||
if (loading) {
|
|
||||||
return <div className="panel-empty">加载中...</div>;
|
|
||||||
}
|
|
||||||
if (!rows.length) {
|
|
||||||
return <div className="panel-empty">{emptyText}</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="self-game-table-wrap">
|
|
||||||
<table className={["self-game-table", wide ? "self-game-table--wide" : ""].filter(Boolean).join(" ")}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<th key={column.key}>{column.label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, rowIndex) => (
|
|
||||||
<tr key={rowKey(row, rowIndex)}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<td key={column.key}>{column.render(row)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowKey(row, rowIndex) {
|
|
||||||
return [row.game_id, row.user_id, row.metric, row.stake_coin, row.event_name, row.reason, row.participant_type, row.result, row.rps_gesture, row.dice_point, rowIndex]
|
|
||||||
.filter((item) => item !== undefined && item !== null && item !== "")
|
|
||||||
.join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function realUserResultCounts(items, totals) {
|
|
||||||
const result = { countNote: "按 participant_distribution 中真实用户结果统计", draw: 0, lose: 0, win: 0 };
|
|
||||||
let userRows = 0;
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!isRealUserParticipant(item.participant_type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userRows += 1;
|
|
||||||
const count = numberValue(item.participant_count);
|
|
||||||
if (item.result === "win") {
|
|
||||||
result.win += count;
|
|
||||||
} else if (item.result === "lose") {
|
|
||||||
result.lose += count;
|
|
||||||
} else if (item.result === "draw") {
|
|
||||||
result.draw += count;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (userRows > 0) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
countNote: "缺少真实用户分布时回退 match_totals 参与人结果",
|
|
||||||
draw: numberValue(totals.draw_count),
|
|
||||||
lose: numberValue(totals.loser_count),
|
|
||||||
win: numberValue(totals.winner_count)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRealUserParticipant(value) {
|
|
||||||
return value === "user" || value === "human";
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestPoolBalance(items) {
|
|
||||||
if (!items.length) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return items.reduce((balance, item) => {
|
|
||||||
const next = numberValue(item.balance_after_coin);
|
|
||||||
return next !== 0 ? next : balance;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function directionLabel(value) {
|
|
||||||
if (value === "in") {
|
|
||||||
return "入池";
|
|
||||||
}
|
|
||||||
if (value === "out") {
|
|
||||||
return "出池";
|
|
||||||
}
|
|
||||||
return value || "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
function participantLabel(value) {
|
|
||||||
if (value === "robot") {
|
|
||||||
return "机器人";
|
|
||||||
}
|
|
||||||
if (isRealUserParticipant(value)) {
|
|
||||||
return "真人";
|
|
||||||
}
|
|
||||||
return value || "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultLabel(value) {
|
|
||||||
if (value === "win") {
|
|
||||||
return "胜";
|
|
||||||
}
|
|
||||||
if (value === "lose") {
|
|
||||||
return "负";
|
|
||||||
}
|
|
||||||
if (value === "draw") {
|
|
||||||
return "平";
|
|
||||||
}
|
|
||||||
return value || "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
function playLabel(row) {
|
|
||||||
if (row.rps_gesture) {
|
|
||||||
return row.rps_gesture;
|
|
||||||
}
|
|
||||||
if (numberValue(row.dice_point) > 0) {
|
|
||||||
return `${row.dice_point} 点`;
|
|
||||||
}
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
@ -106,7 +106,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
],
|
],
|
||||||
sideMetrics: [
|
sideMetrics: [
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, activeUsers)) },
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
|
||||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
||||||
],
|
],
|
||||||
topCountries: countryBreakdown.slice(0, 4),
|
topCountries: countryBreakdown.slice(0, 4),
|
||||||
@ -223,11 +223,8 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
||||||
const meta = resolveCountryMeta({ code, name: country });
|
const meta = resolveCountryMeta({ code, name: country });
|
||||||
const recharge = effectiveRechargeUSDMinor(item);
|
const recharge = effectiveRechargeUSDMinor(item);
|
||||||
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);
|
|
||||||
return {
|
return {
|
||||||
active_users: activeUsers,
|
active_users: numberValue(item.active_users),
|
||||||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
||||||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
||||||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
||||||
@ -237,13 +234,11 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
flag: countryFlag(code || meta?.code),
|
flag: countryFlag(code || meta?.code),
|
||||||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||||
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
||||||
paid_users: paidUsers,
|
paid_users: numberValue(item.paid_users),
|
||||||
payer_rate: ratio(paidUsers, activeUsers),
|
payer_rate: ratio(item.paid_users, item.active_users),
|
||||||
recharge_usd_minor: recharge,
|
recharge_usd_minor: recharge,
|
||||||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
|
||||||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers),
|
||||||
: ratio(rechargeUsers, activeUsers),
|
|
||||||
recharge_users: rechargeUsers,
|
|
||||||
share: recharge / totalRecharge,
|
share: recharge / totalRecharge,
|
||||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||||
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
|
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
|
||||||
|
|||||||
@ -67,30 +67,6 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
|
|||||||
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
|
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses active users as payer and recharge conversion denominator", () => {
|
|
||||||
const model = createDashboardModel({
|
|
||||||
active_users: 80,
|
|
||||||
country_breakdown: [
|
|
||||||
{
|
|
||||||
active_users: 20,
|
|
||||||
country: "巴西",
|
|
||||||
paid_users: 8,
|
|
||||||
recharge_users: 6,
|
|
||||||
recharge_usd_minor: 500
|
|
||||||
}
|
|
||||||
],
|
|
||||||
new_users: 4,
|
|
||||||
paid_users: 8,
|
|
||||||
recharge_users: 6
|
|
||||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
|
||||||
|
|
||||||
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
|
|
||||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
|
||||||
payer_rate: 0.4,
|
|
||||||
recharge_conversion_rate: 0.3
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
function metricValue(model, label) {
|
function metricValue(model, label) {
|
||||||
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
|
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,6 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.databi-screen--self-game {
|
|
||||||
grid-template-rows: 72px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.databi-header {
|
.databi-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -76,40 +72,6 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-switch,
|
|
||||||
.game-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen-switch button,
|
|
||||||
.game-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen-switch button:hover,
|
|
||||||
.game-switch button:hover,
|
|
||||||
.screen-switch button.is-active,
|
|
||||||
.game-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-meta {
|
.header-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -131,14 +93,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-trigger {
|
.filter-trigger {
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 138px;
|
min-width: 138px;
|
||||||
height: 34px;
|
height: 44px;
|
||||||
grid-template-columns: minmax(0, 1fr) 18px;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 10px;
|
||||||
padding: 7px 28px 3px 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(7, 29, 48, 0.9);
|
background: rgba(7, 29, 48, 0.9);
|
||||||
@ -168,13 +129,6 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-trigger:hover .filter-label,
|
|
||||||
.filter-trigger[aria-expanded="true"] .filter-label,
|
|
||||||
.date-range-trigger:hover .date-range-label,
|
|
||||||
.date-range-trigger[aria-expanded="true"] .date-range-label {
|
|
||||||
color: #27e4f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-trigger:disabled {
|
.filter-trigger:disabled {
|
||||||
cursor: progress;
|
cursor: progress;
|
||||||
opacity: 0.68;
|
opacity: 0.68;
|
||||||
@ -185,24 +139,10 @@
|
|||||||
color: #cbd9e9;
|
color: #cbd9e9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-icon {
|
||||||
position: absolute;
|
display: grid;
|
||||||
top: -8px;
|
min-width: 20px;
|
||||||
left: 10px;
|
place-items: center;
|
||||||
display: inline-flex;
|
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: rgba(2, 15, 31, 0.98);
|
|
||||||
color: #7f9bb7;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 760;
|
|
||||||
line-height: 14px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-glyph {
|
.control-glyph {
|
||||||
@ -212,46 +152,38 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-icon {
|
.filter-copy {
|
||||||
display: inline-grid;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 16px;
|
|
||||||
place-items: center;
|
|
||||||
color: #cbd9e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label .filter-icon {
|
|
||||||
color: #27e4f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-value {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-copy small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #e6f3ff;
|
color: #7f9bb7;
|
||||||
font-size: 13px;
|
font-size: 10px;
|
||||||
font-weight: 720;
|
line-height: 1.1;
|
||||||
line-height: 18px;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chevron {
|
.filter-copy b {
|
||||||
position: absolute;
|
overflow: hidden;
|
||||||
top: 50%;
|
color: #e6f3ff;
|
||||||
right: 8px;
|
font-size: 13px;
|
||||||
transform: translateY(-50%);
|
font-weight: 720;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-range-trigger {
|
.date-range-trigger {
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 318px;
|
width: 336px;
|
||||||
height: 34px;
|
height: 44px;
|
||||||
grid-template-columns: minmax(0, 1fr) 18px;
|
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 10px;
|
||||||
padding: 7px 28px 3px 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(7, 29, 48, 0.9);
|
background: rgba(7, 29, 48, 0.9);
|
||||||
@ -268,46 +200,49 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-range-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
color: #cbd9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-icon svg {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-copy {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.date-range-label {
|
.date-range-label {
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
left: 10px;
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: rgba(2, 15, 31, 0.98);
|
|
||||||
color: #7f9bb7;
|
color: #7f9bb7;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 760;
|
font-weight: 720;
|
||||||
line-height: 14px;
|
line-height: 1.05;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-range-label svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: #cbd9e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-range-values {
|
.date-range-values {
|
||||||
display: flex;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(0, auto) 16px minmax(0, auto);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-range-values b {
|
.date-range-values b {
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #eaf6ff;
|
color: #eaf6ff;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
line-height: 18px;
|
line-height: 1.15;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -320,11 +255,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-range-chevron {
|
.date-range-chevron {
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 8px;
|
|
||||||
color: #cbd9e9;
|
color: #cbd9e9;
|
||||||
transform: translateY(-50%);
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-popover {
|
.filter-popover {
|
||||||
@ -718,15 +650,6 @@
|
|||||||
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
|
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-error {
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #f7a33a;
|
|
||||||
font-size: 12px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
@ -862,73 +785,3 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-game-screen {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 44px auto repeat(3, minmax(236px, auto));
|
|
||||||
gap: 16px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-title {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-title strong {
|
|
||||||
color: #f3f9ff;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 780;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-title span {
|
|
||||||
color: #7f9bb7;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-kpi-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-kpi-grid .metric-card {
|
|
||||||
grid-column: span 1;
|
|
||||||
height: 118px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-kpi-grid .metric-content {
|
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-kpi-grid .metric-content strong {
|
|
||||||
font-size: clamp(16px, 0.98vw, 21px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-panel-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-panel-grid--single {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-panel-grid .databi-panel {
|
|
||||||
min-height: 236px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-panel-grid--single .databi-panel {
|
|
||||||
min-height: 190px;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -133,121 +133,3 @@
|
|||||||
color: #7f9bb7;
|
color: #7f9bb7;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-game-table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 520px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-table--wide {
|
|
||||||
min-width: 980px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-table th,
|
|
||||||
.self-game-table td {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
|
|
||||||
color: #d8ebff;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-table th {
|
|
||||||
background: rgba(128, 171, 211, 0.08);
|
|
||||||
color: #9eb7ce;
|
|
||||||
font-weight: 720;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-game-table th:first-child,
|
|
||||||
.self-game-table td:first-child,
|
|
||||||
.self-game-table th:nth-child(2),
|
|
||||||
.self-game-table td:nth-child(2) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border: 1px solid rgba(81, 154, 203, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(6, 27, 46, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-row strong {
|
|
||||||
color: #e9f6ff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 760;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-row span {
|
|
||||||
color: #9eb7ce;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-cell {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: 30px minmax(0, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-avatar {
|
|
||||||
display: grid;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid rgba(39, 228, 245, 0.28);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(39, 228, 245, 0.12);
|
|
||||||
color: #e9f6ff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 760;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-avatar--empty {
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-copy {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-copy strong,
|
|
||||||
.risk-user-copy small {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-copy strong {
|
|
||||||
color: #e9f6ff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 760;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-user-copy small {
|
|
||||||
color: #88a7c2;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -155,7 +155,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
<TextField
|
<TextField
|
||||||
className="app-switch"
|
className="app-switch"
|
||||||
disabled={appScope.loading || !appScope.apps.length}
|
disabled={appScope.loading || !appScope.apps.length}
|
||||||
label="应用"
|
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
value={appScope.appCode}
|
value={appScope.appCode}
|
||||||
@ -173,7 +172,6 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
className="timezone-switch"
|
className="timezone-switch"
|
||||||
label="时区"
|
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
slotProps={{ htmlInput: { "aria-label": "显示时区" } }}
|
slotProps={{ htmlInput: { "aria-label": "显示时区" } }}
|
||||||
@ -187,11 +185,8 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||||
<span className="search-trigger__label">
|
<Search fontSize="small" />
|
||||||
<Search fontSize="inherit" />
|
<span>搜索菜单、用户、角色...</span>
|
||||||
<span>搜索</span>
|
|
||||||
</span>
|
|
||||||
<span className="search-trigger__value">搜索菜单、用户、角色...</span>
|
|
||||||
</button>
|
</button>
|
||||||
<IconButton label="刷新" onClick={refresh}>
|
<IconButton label="刷新" onClick={refresh}>
|
||||||
<Refresh fontSize="small" />
|
<Refresh fontSize="small" />
|
||||||
|
|||||||
@ -211,46 +211,6 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coinButton {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 28px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: transparent;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coinButton:hover,
|
|
||||||
.coinButton:focus-visible {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration-color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coinButton:focus-visible {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
outline: 2px solid var(--primary-border-strong);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coinLedgerDrawer.coinLedgerDrawer {
|
|
||||||
width: min(960px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.coinLedgerDrawerBody.coinLedgerDrawerBody {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -165,11 +165,6 @@ export function useAppUsersPage() {
|
|||||||
setActiveAction("country");
|
setActiveAction("country");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCoinLedger = (user) => {
|
|
||||||
setActiveUser(user);
|
|
||||||
setActiveAction("coin-ledger");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setActiveUser(null);
|
setActiveUser(null);
|
||||||
@ -315,7 +310,6 @@ export function useAppUsersPage() {
|
|||||||
locationFilterOptions,
|
locationFilterOptions,
|
||||||
locationFilterValue,
|
locationFilterValue,
|
||||||
openCountry,
|
openCountry,
|
||||||
openCoinLedger,
|
|
||||||
openEdit,
|
openEdit,
|
||||||
openPassword,
|
openPassword,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@ -16,14 +16,12 @@ import { Button } from "@/shared/ui/Button.jsx";
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
export function AppUserListPage() {
|
export function AppUserListPage() {
|
||||||
@ -117,7 +115,7 @@ export function AppUserListPage() {
|
|||||||
),
|
),
|
||||||
label: "金币",
|
label: "金币",
|
||||||
width: "minmax(90px, 0.6fr)",
|
width: "minmax(90px, 0.6fr)",
|
||||||
render: (user) => <CoinValue page={page} user={user} />,
|
render: (user) => formatNumber(user.coin),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@ -282,40 +280,10 @@ export function AppUserListPage() {
|
|||||||
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</ActionModal>
|
</ActionModal>
|
||||||
|
|
||||||
<SideDrawer
|
|
||||||
className={styles.coinLedgerDrawer}
|
|
||||||
contentClassName={styles.coinLedgerDrawerBody}
|
|
||||||
open={page.activeAction === "coin-ledger"}
|
|
||||||
title={`金币流水 - ${userLedgerTitle(page.activeUser)}`}
|
|
||||||
width="wide"
|
|
||||||
onClose={page.closeAction}
|
|
||||||
>
|
|
||||||
{page.activeUser ? (
|
|
||||||
<CoinLedgerTable lockedUser={page.activeUser} userId={page.activeUser.userId} />
|
|
||||||
) : null}
|
|
||||||
</SideDrawer>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoinValue({ page, user }) {
|
|
||||||
const value = formatNumber(user.coin);
|
|
||||||
if (!page.abilities.canCoinLedger) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={`查看 ${userLedgerTitle(user)} 的金币流水`}
|
|
||||||
className={styles.coinButton}
|
|
||||||
type="button"
|
|
||||||
onClick={() => page.openCoinLedger(user)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserLocation({ page, user }) {
|
function UserLocation({ page, user }) {
|
||||||
const countryLabel = formatCountry(user);
|
const countryLabel = formatCountry(user);
|
||||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||||
@ -424,13 +392,6 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function userLedgerTitle(user) {
|
|
||||||
if (!user) {
|
|
||||||
return "用户";
|
|
||||||
}
|
|
||||||
return user.username || user.displayUserId || user.prettyDisplayUserId || user.userId || "用户";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountry(user) {
|
function formatCountry(user) {
|
||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { AppUserListPage } from "./AppUserListPage.jsx";
|
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
ledgerTable: vi.fn(),
|
|
||||||
openCoinLedger: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/app-users/hooks/useAppUsersPage.js", () => ({
|
|
||||||
useAppUsersPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/operations/components/CoinLedgerTable.jsx", () => ({
|
|
||||||
CoinLedgerTable: (props) => {
|
|
||||||
mocks.ledgerTable(props);
|
|
||||||
return <div data-testid="coin-ledger-table">{props.userId}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
|
||||||
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
|
||||||
|
|
||||||
render(<AppUserListPage />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
|
||||||
|
|
||||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coin ledger drawer passes the active app user to the shared ledger table", () => {
|
|
||||||
const activeUser = userFixture();
|
|
||||||
vi.mocked(useAppUsersPage).mockReturnValue(
|
|
||||||
pageFixture({
|
|
||||||
activeAction: "coin-ledger",
|
|
||||||
activeUser,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<AppUserListPage />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
|
|
||||||
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ lockedUser: activeUser, userId: "10001" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: {
|
|
||||||
canCoinLedger: true,
|
|
||||||
canPassword: true,
|
|
||||||
canStatus: true,
|
|
||||||
canUpdate: true,
|
|
||||||
canUpload: true,
|
|
||||||
canView: true,
|
|
||||||
},
|
|
||||||
activeAction: "",
|
|
||||||
activeUser: null,
|
|
||||||
batchBan: vi.fn(),
|
|
||||||
changeLocationFilter: vi.fn(),
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeSort: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
changeTimeRange: vi.fn(),
|
|
||||||
closeAction: vi.fn(),
|
|
||||||
countryForm: { country: "" },
|
|
||||||
countryOptions: [],
|
|
||||||
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
|
||||||
editForm: { avatar: "", gender: "", username: "" },
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
loadingAction: "",
|
|
||||||
loadingCountries: false,
|
|
||||||
loadingRegions: false,
|
|
||||||
locationFilterOptions: [],
|
|
||||||
locationFilterValue: "",
|
|
||||||
openCoinLedger: mocks.openCoinLedger,
|
|
||||||
openCountry: vi.fn(),
|
|
||||||
openEdit: vi.fn(),
|
|
||||||
openPassword: vi.fn(),
|
|
||||||
page: 1,
|
|
||||||
passwordForm: { password: "" },
|
|
||||||
query: "",
|
|
||||||
regionOptions: [],
|
|
||||||
reload: vi.fn(),
|
|
||||||
selectedUserIds: [],
|
|
||||||
setCountryForm: vi.fn(),
|
|
||||||
setEditForm: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
setPasswordForm: vi.fn(),
|
|
||||||
setSelectedUserIds: vi.fn(),
|
|
||||||
sortBy: "created_at",
|
|
||||||
sortDirection: "desc",
|
|
||||||
status: "",
|
|
||||||
submitCountry: vi.fn(),
|
|
||||||
submitEdit: vi.fn(),
|
|
||||||
submitPassword: vi.fn(),
|
|
||||||
timeRange: { endMs: "", startMs: "" },
|
|
||||||
toggleBan: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function userFixture() {
|
|
||||||
return {
|
|
||||||
avatar: "",
|
|
||||||
coin: 223437,
|
|
||||||
country: "AE",
|
|
||||||
countryDisplayName: "阿拉伯联合酋长国",
|
|
||||||
createdAtMs: 1779322523000,
|
|
||||||
displayUserId: "163337",
|
|
||||||
lastActiveAtMs: 1781325798000,
|
|
||||||
regionId: 2,
|
|
||||||
regionName: "中东区",
|
|
||||||
status: "active",
|
|
||||||
userId: "10001",
|
|
||||||
username: "d",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -558,7 +558,7 @@ function GrantDialog({ page }) {
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="发放信息">
|
<AdminFormSection title="发放信息">
|
||||||
<TextField
|
<TextField
|
||||||
label="用户 ID / 短 ID"
|
label="用户 ID"
|
||||||
required
|
required
|
||||||
value={page.grantForm.targetUserId}
|
value={page.grantForm.targetUserId}
|
||||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })}
|
onChange={(event) => page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export function useAppUserAbilities() {
|
|||||||
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||||
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||||
canPrettyView: can(PERMISSIONS.prettyIdView),
|
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||||
canCoinLedger: can(PERMISSIONS.coinLedgerView),
|
|
||||||
canStatus: can(PERMISSIONS.appUserStatus),
|
canStatus: can(PERMISSIONS.appUserStatus),
|
||||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
|
|||||||
@ -98,7 +98,10 @@ export const prettyDisplayIDGrantSchema = z.object({
|
|||||||
return Number.isInteger(days) && days >= 0 && days <= 36500;
|
return Number.isInteger(days) && days >= 0 && days <= 36500;
|
||||||
}, "有效天数必须是 0 到 36500 的整数"),
|
}, "有效天数必须是 0 到 36500 的整数"),
|
||||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||||
targetUserId: z.string().trim().min(1, "请输入用户 ID 或短 ID").max(64, "用户 ID 或短 ID 不能超过 64 个字符"),
|
targetUserId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[1-9]\d*$/, "请输入用户 ID"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。
|
// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export interface CumulativeRechargeRewardConfigPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CumulativeRechargeRewardGrantUserDto {
|
export interface CumulativeRechargeRewardGrantUserDto {
|
||||||
userId?: string;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
@ -52,7 +52,7 @@ export interface CumulativeRechargeRewardGrantDto {
|
|||||||
eventId?: string;
|
eventId?: string;
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
user?: CumulativeRechargeRewardGrantUserDto;
|
user?: CumulativeRechargeRewardGrantUserDto;
|
||||||
tierId?: number;
|
tierId?: number;
|
||||||
tierCode?: string;
|
tierCode?: string;
|
||||||
@ -143,9 +143,9 @@ function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
|
|||||||
eventId: stringValue(item.eventId ?? item.event_id),
|
eventId: stringValue(item.eventId ?? item.event_id),
|
||||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
user: {
|
user: {
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export interface FirstRechargeRewardConfigPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstRechargeRewardClaimUserDto {
|
export interface FirstRechargeRewardClaimUserDto {
|
||||||
userId?: string;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
@ -58,7 +58,7 @@ export interface FirstRechargeRewardClaimDto {
|
|||||||
eventId?: string;
|
eventId?: string;
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
user?: FirstRechargeRewardClaimUserDto;
|
user?: FirstRechargeRewardClaimUserDto;
|
||||||
tierId?: number;
|
tierId?: number;
|
||||||
tierCode?: string;
|
tierCode?: string;
|
||||||
@ -151,9 +151,9 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
|||||||
eventId: stringValue(item.eventId ?? item.event_id),
|
eventId: stringValue(item.eventId ?? item.event_id),
|
||||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
user: {
|
user: {
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||||
|
|||||||
@ -96,46 +96,6 @@ export interface DiceConfigDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelfGameNewUserPolicyDto {
|
|
||||||
appCode?: string;
|
|
||||||
gameId: string;
|
|
||||||
enabled: boolean;
|
|
||||||
protectionRounds: number;
|
|
||||||
protectionHours: number;
|
|
||||||
maxStakeCoin: number;
|
|
||||||
lifetimeSubsidyQuotaCoin: number;
|
|
||||||
day1SubsidyQuotaCoin: number;
|
|
||||||
singleRoundSubsidyCapCoin: number;
|
|
||||||
strategyLevel: string;
|
|
||||||
loseStreakProtectionEnabled: boolean;
|
|
||||||
loseStreakTrigger: number;
|
|
||||||
normalPhaseTargetWinRatePercent: number;
|
|
||||||
blackPoolRobotForceWinEnabled: boolean;
|
|
||||||
createdAtMs?: number;
|
|
||||||
updatedAtMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelfGameStakePoolDto {
|
|
||||||
appCode?: string;
|
|
||||||
gameId: string;
|
|
||||||
stakeCoin: number;
|
|
||||||
balanceCoin: number;
|
|
||||||
reservedCoin: number;
|
|
||||||
availableCoin: number;
|
|
||||||
targetBalanceCoin: number;
|
|
||||||
safeBalanceCoin: number;
|
|
||||||
warningBalanceCoin: number;
|
|
||||||
dangerBalanceCoin: number;
|
|
||||||
status: string;
|
|
||||||
poolLevel: string;
|
|
||||||
requiredPoolCoin: number;
|
|
||||||
humanWinCapacity: number;
|
|
||||||
botMatchEnabled: boolean;
|
|
||||||
blackReason?: string;
|
|
||||||
createdAtMs?: number;
|
|
||||||
updatedAtMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiceRobotDto {
|
export interface DiceRobotDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@ -218,14 +178,13 @@ export interface RoomRpsChallengePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
||||||
export type SelfGameNewUserPolicyPayload = Omit<SelfGameNewUserPolicyDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
|
||||||
|
|
||||||
export interface RoomRpsConfigPayload {
|
export interface RoomRpsConfigPayload {
|
||||||
status: string;
|
status: string;
|
||||||
challengeTimeoutMs: number;
|
challengeTimeoutMs: number;
|
||||||
revealCountdownMs: number;
|
revealCountdownMs: number;
|
||||||
stakeGifts: Array<{
|
stakeGifts: Array<{
|
||||||
giftId: string;
|
giftId: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}>;
|
}>;
|
||||||
@ -237,11 +196,6 @@ export interface DicePoolAdjustPayload {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelfGameStakePoolPayload = Pick<
|
|
||||||
SelfGameStakePoolDto,
|
|
||||||
"targetBalanceCoin" | "safeBalanceCoin" | "warningBalanceCoin" | "dangerBalanceCoin" | "status"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface DiceGenerateRobotsPayload {
|
export interface DiceGenerateRobotsPayload {
|
||||||
count: number;
|
count: number;
|
||||||
nicknameLanguage?: "english" | "arabic";
|
nicknameLanguage?: "english" | "arabic";
|
||||||
@ -374,54 +328,14 @@ export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Pr
|
|||||||
).then(normalizeDiceConfig);
|
).then(normalizeDiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelfGameNewUserPolicy(gameId: string): Promise<SelfGameNewUserPolicyDto> {
|
export function adjustDicePool(
|
||||||
return apiRequest<SelfGameNewUserPolicyDto>(
|
|
||||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
|
||||||
{ method: "GET" },
|
|
||||||
).then(normalizeSelfGameNewUserPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSelfGameNewUserPolicy(
|
|
||||||
gameId: string,
|
gameId: string,
|
||||||
payload: SelfGameNewUserPolicyPayload,
|
|
||||||
): Promise<SelfGameNewUserPolicyDto> {
|
|
||||||
return apiRequest<SelfGameNewUserPolicyDto, SelfGameNewUserPolicyPayload>(
|
|
||||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
|
||||||
{ body: payload, method: "PATCH" },
|
|
||||||
).then(normalizeSelfGameNewUserPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSelfGameStakePools(
|
|
||||||
gameId: string,
|
|
||||||
): Promise<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }> {
|
|
||||||
return apiRequest<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }>(
|
|
||||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools`,
|
|
||||||
{ method: "GET" },
|
|
||||||
).then((page) => ({
|
|
||||||
...page,
|
|
||||||
items: (page.items || []).map(normalizeSelfGameStakePool),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSelfGameStakePool(
|
|
||||||
gameId: string,
|
|
||||||
stakeCoin: number,
|
|
||||||
payload: SelfGameStakePoolPayload,
|
|
||||||
): Promise<SelfGameStakePoolDto> {
|
|
||||||
return apiRequest<SelfGameStakePoolDto, SelfGameStakePoolPayload>(
|
|
||||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}`,
|
|
||||||
{ body: payload, method: "PATCH" },
|
|
||||||
).then(normalizeSelfGameStakePool);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adjustSelfGameStakePool(
|
|
||||||
gameId: string,
|
|
||||||
stakeCoin: number,
|
|
||||||
payload: DicePoolAdjustPayload,
|
payload: DicePoolAdjustPayload,
|
||||||
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
||||||
|
const endpoint = API_ENDPOINTS.adjustDicePool;
|
||||||
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
||||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}/adjust`,
|
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
|
||||||
{ body: payload, method: "POST" },
|
{ body: payload, method: endpoint.method },
|
||||||
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,19 +358,19 @@ export function generateDiceRobots(payload: DiceGenerateRobotsPayload) {
|
|||||||
).then((result) => ({ ...result, items: (result.items || []).map(normalizeDiceRobot) }));
|
).then((result) => ({ ...result, items: (result.items || []).map(normalizeDiceRobot) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDiceRobotStatus(userId: string, status: string, gameId = "") {
|
export function setDiceRobotStatus(userId: string, status: string, gameId = "dice") {
|
||||||
const endpoint = API_ENDPOINTS.setDiceRobotStatus;
|
const endpoint = API_ENDPOINTS.setDiceRobotStatus;
|
||||||
return apiRequest<DiceRobotDto, { status: string }>(
|
return apiRequest<DiceRobotDto, { status: string }>(
|
||||||
apiEndpointPath(API_OPERATIONS.setDiceRobotStatus, { user_id: userId }),
|
apiEndpointPath(API_OPERATIONS.setDiceRobotStatus, { user_id: userId }),
|
||||||
{ body: { status }, method: endpoint.method, query: gameId ? { gameId } : undefined },
|
{ body: { status }, method: endpoint.method, query: { gameId } },
|
||||||
).then(normalizeDiceRobot);
|
).then(normalizeDiceRobot);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDiceRobot(userId: string, gameId = ""): Promise<{ deleted: boolean }> {
|
export function deleteDiceRobot(userId: string, gameId = "dice"): Promise<{ deleted: boolean }> {
|
||||||
const endpoint = API_ENDPOINTS.deleteDiceRobot;
|
const endpoint = API_ENDPOINTS.deleteDiceRobot;
|
||||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteDiceRobot, { user_id: userId }), {
|
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteDiceRobot, { user_id: userId }), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query: gameId ? { gameId } : undefined,
|
query: { gameId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,50 +500,6 @@ function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSelfGameNewUserPolicy(policy: Partial<SelfGameNewUserPolicyDto>): SelfGameNewUserPolicyDto {
|
|
||||||
return {
|
|
||||||
appCode: stringValue(policy.appCode),
|
|
||||||
gameId: stringValue(policy.gameId || "dice"),
|
|
||||||
enabled: policy.enabled !== false,
|
|
||||||
protectionRounds: numberValue(policy.protectionRounds || 30),
|
|
||||||
protectionHours: numberValue(policy.protectionHours || 168),
|
|
||||||
maxStakeCoin: numberValue(policy.maxStakeCoin || 5000),
|
|
||||||
lifetimeSubsidyQuotaCoin: numberValue(policy.lifetimeSubsidyQuotaCoin || 30000),
|
|
||||||
day1SubsidyQuotaCoin: numberValue(policy.day1SubsidyQuotaCoin || 15000),
|
|
||||||
singleRoundSubsidyCapCoin: numberValue(policy.singleRoundSubsidyCapCoin || 5000),
|
|
||||||
strategyLevel: stringValue(policy.strategyLevel || "soft_boost"),
|
|
||||||
loseStreakProtectionEnabled: policy.loseStreakProtectionEnabled !== false,
|
|
||||||
loseStreakTrigger: numberValue(policy.loseStreakTrigger || 2),
|
|
||||||
normalPhaseTargetWinRatePercent: numberValue(policy.normalPhaseTargetWinRatePercent || 53),
|
|
||||||
blackPoolRobotForceWinEnabled: policy.blackPoolRobotForceWinEnabled === true,
|
|
||||||
createdAtMs: numberValue(policy.createdAtMs),
|
|
||||||
updatedAtMs: numberValue(policy.updatedAtMs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSelfGameStakePool(pool: Partial<SelfGameStakePoolDto>): SelfGameStakePoolDto {
|
|
||||||
return {
|
|
||||||
appCode: stringValue(pool.appCode),
|
|
||||||
gameId: stringValue(pool.gameId || "dice"),
|
|
||||||
stakeCoin: numberValue(pool.stakeCoin),
|
|
||||||
balanceCoin: numberValue(pool.balanceCoin),
|
|
||||||
reservedCoin: numberValue(pool.reservedCoin),
|
|
||||||
availableCoin: numberValue(pool.availableCoin),
|
|
||||||
targetBalanceCoin: numberValue(pool.targetBalanceCoin),
|
|
||||||
safeBalanceCoin: numberValue(pool.safeBalanceCoin),
|
|
||||||
warningBalanceCoin: numberValue(pool.warningBalanceCoin),
|
|
||||||
dangerBalanceCoin: numberValue(pool.dangerBalanceCoin),
|
|
||||||
status: stringValue(pool.status || "active"),
|
|
||||||
poolLevel: stringValue(pool.poolLevel || "black"),
|
|
||||||
requiredPoolCoin: numberValue(pool.requiredPoolCoin),
|
|
||||||
humanWinCapacity: numberValue(pool.humanWinCapacity),
|
|
||||||
botMatchEnabled: pool.botMatchEnabled === true,
|
|
||||||
blackReason: stringValue(pool.blackReason),
|
|
||||||
createdAtMs: numberValue(pool.createdAtMs),
|
|
||||||
updatedAtMs: numberValue(pool.updatedAtMs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
||||||
return {
|
return {
|
||||||
appCode: stringValue(robot.appCode),
|
appCode: stringValue(robot.appCode),
|
||||||
|
|||||||
@ -65,221 +65,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.giftTierList {
|
.giftTierList {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.giftTierRow {
|
.giftTierRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierGift {
|
|
||||||
min-height: 78px;
|
|
||||||
padding: 8px;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierGift:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierControls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 90px 58px;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierSort {
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierSort :global(.MuiOutlinedInput-root) {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierSwitch {
|
|
||||||
min-height: 40px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullField {
|
.fullField {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategyLevelLabel {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategyLevelHelpIcon {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategyLevelTooltipPaper {
|
|
||||||
max-width: min(560px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategyLevelTooltip {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
color: inherit;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategyLevelTooltipTitle {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategyLevelTooltipList {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolList {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolRow {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolRow:hover,
|
|
||||||
.stakePoolRowActive {
|
|
||||||
border-color: var(--primary-border);
|
|
||||||
background: var(--primary-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolMain {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolStake {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolMetrics {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolWarning {
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolEmpty {
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolHint {
|
|
||||||
margin-top: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolInlineActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolPrimaryButton {
|
|
||||||
min-height: var(--control-height);
|
|
||||||
padding: 0 18px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stakePoolPrimaryButton:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLevelBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 42px;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLevel_green {
|
|
||||||
background: rgba(16, 185, 129, 0.12);
|
|
||||||
color: #047857;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLevel_yellow {
|
|
||||||
background: rgba(245, 158, 11, 0.14);
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLevel_red {
|
|
||||||
background: rgba(239, 68, 68, 0.12);
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poolLevel_black,
|
|
||||||
.poolLevel_unknown {
|
|
||||||
background: rgba(15, 23, 42, 0.12);
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.giftTierRow {
|
.giftTierRow {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.giftTierControls {
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.giftTierSort {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformList {
|
.platformList {
|
||||||
|
|||||||
@ -30,7 +30,6 @@ const defaultGenerateForm = {
|
|||||||
count: 20,
|
count: 20,
|
||||||
nicknameLanguage: "arabic",
|
nicknameLanguage: "arabic",
|
||||||
};
|
};
|
||||||
const maxGenerateRobotCount = 1000;
|
|
||||||
|
|
||||||
export function GameRobotPage() {
|
export function GameRobotPage() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
@ -51,7 +50,7 @@ export function GameRobotPage() {
|
|||||||
async (cursor = "", append = false) => {
|
async (cursor = "", append = false) => {
|
||||||
setLoading(!append);
|
setLoading(!append);
|
||||||
try {
|
try {
|
||||||
const result = await listDiceRobots({ status, cursor, pageSize: 50 });
|
const result = await listDiceRobots({ gameId: "dice", status, cursor, pageSize: 50 });
|
||||||
setItems((current) => (append ? [...current, ...(result.items || [])] : result.items || []));
|
setItems((current) => (append ? [...current, ...(result.items || [])] : result.items || []));
|
||||||
setNextCursor(result.nextCursor || "");
|
setNextCursor(result.nextCursor || "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -71,7 +70,7 @@ export function GameRobotPage() {
|
|||||||
async (robot, checked) => {
|
async (robot, checked) => {
|
||||||
setLoadingAction(`status-${robot.userId}`);
|
setLoadingAction(`status-${robot.userId}`);
|
||||||
try {
|
try {
|
||||||
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled");
|
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled", robot.gameId);
|
||||||
await load("", false);
|
await load("", false);
|
||||||
showToast("机器人状态已更新", "success");
|
showToast("机器人状态已更新", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -96,7 +95,7 @@ export function GameRobotPage() {
|
|||||||
}
|
}
|
||||||
setLoadingAction(`delete-${robot.userId}`);
|
setLoadingAction(`delete-${robot.userId}`);
|
||||||
try {
|
try {
|
||||||
await deleteDiceRobot(robot.userId);
|
await deleteDiceRobot(robot.userId, robot.gameId || "dice");
|
||||||
await load("", false);
|
await load("", false);
|
||||||
showToast("全站机器人已删除", "success");
|
showToast("全站机器人已删除", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -266,21 +265,21 @@ function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
>
|
>
|
||||||
<AdminFormFieldGrid>
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<TextField
|
||||||
helperText={`最多 ${maxGenerateRobotCount} 个`}
|
helperText="最多 200 个"
|
||||||
label="数量"
|
label="数量"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.count}
|
value={form.count}
|
||||||
slotProps={{ htmlInput: { max: maxGenerateRobotCount, min: 1 } }}
|
slotProps={{ htmlInput: { max: 200, min: 1 } }}
|
||||||
onChange={(event) => setForm({ ...form, count: event.target.value })}
|
onChange={(event) => setForm({ ...form, count: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="账号语言"
|
label="昵称语言"
|
||||||
select
|
select
|
||||||
value={form.nicknameLanguage || "arabic"}
|
value={form.nicknameLanguage || "arabic"}
|
||||||
onChange={(event) => setForm({ ...form, nicknameLanguage: event.target.value })}
|
onChange={(event) => setForm({ ...form, nicknameLanguage: event.target.value })}
|
||||||
>
|
>
|
||||||
<MenuItem value="arabic">阿拉伯语账号</MenuItem>
|
<MenuItem value="arabic">阿拉伯语名称</MenuItem>
|
||||||
<MenuItem value="english">英语账号</MenuItem>
|
<MenuItem value="english">英语名称</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ test("renders game robot page without crashing", async () => {
|
|||||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows account language selector when generating robots", async () => {
|
test("shows nickname language selector when generating robots", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
@ -46,10 +46,10 @@ test("shows account language selector when generating robots", async () => {
|
|||||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||||
await user.click(screen.getByRole("button", { name: "批量创建" }));
|
await user.click(screen.getByRole("button", { name: "批量创建" }));
|
||||||
|
|
||||||
expect(screen.getByLabelText("账号语言")).toBeInTheDocument();
|
expect(screen.getByLabelText("昵称语言")).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("combobox", { name: "账号语言" }));
|
await user.click(screen.getByRole("combobox", { name: "昵称语言" }));
|
||||||
expect(screen.getByRole("option", { name: "阿拉伯语账号" })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: "阿拉伯语名称" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("option", { name: "英语账号" })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: "英语名称" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(body) {
|
function jsonResponse(body) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { PERMISSIONS } from "@/app/permissions";
|
|||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
|
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
|
||||||
import { listGifts } from "@/features/resources/api";
|
import { listGifts } from "@/features/resources/api";
|
||||||
import { GiftPreviewSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
||||||
import styles from "@/features/games/games.module.css";
|
import styles from "@/features/games/games.module.css";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
@ -154,12 +154,11 @@ export function RoomRpsConfigPage() {
|
|||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const validationError = validateStakeGifts(form.stakeGifts);
|
const payload = formToPayload(form);
|
||||||
if (validationError) {
|
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
|
||||||
showToast(validationError, "error");
|
showToast("房内猜拳必须配置 4 个礼物档位", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = formToPayload(form);
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await updateRoomRpsConfig(payload);
|
const result = await updateRoomRpsConfig(payload);
|
||||||
@ -215,7 +214,6 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
|||||||
open={open}
|
open={open}
|
||||||
submitLabel="保存配置"
|
submitLabel="保存配置"
|
||||||
title="房内猜拳配置"
|
title="房内猜拳配置"
|
||||||
size="large"
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
@ -248,40 +246,28 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
|||||||
<div className={styles.giftTierList}>
|
<div className={styles.giftTierList}>
|
||||||
{form.stakeGifts.map((gift, index) => (
|
{form.stakeGifts.map((gift, index) => (
|
||||||
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
||||||
<GiftPreviewSelectField
|
<GiftSelectField
|
||||||
badge={`${Number(gift.sortOrder || index + 1)} 档`}
|
|
||||||
className={styles.giftTierGift}
|
|
||||||
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
||||||
fallbackGift={gift}
|
|
||||||
gifts={gifts}
|
gifts={gifts}
|
||||||
label={`第 ${index + 1} 档礼物`}
|
label={`第 ${index + 1} 档礼物`}
|
||||||
loading={giftsLoading}
|
loading={giftsLoading}
|
||||||
placeholder="点击选择礼物"
|
placeholder="点击选择礼物"
|
||||||
value={gift.giftId}
|
value={gift.giftId}
|
||||||
onChange={(value, selectedGift) =>
|
onChange={(value) => setGiftTier(setForm, form, index, { giftId: value })}
|
||||||
setGiftTier(setForm, form, index, giftPatchFromSelected(value, selectedGift))
|
/>
|
||||||
|
<TextField
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={gift.sortOrder}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className={styles.giftTierControls}>
|
<AdminFormSwitchField
|
||||||
<TextField
|
checked={gift.enabled}
|
||||||
className={styles.giftTierSort}
|
label="启用"
|
||||||
label="排序"
|
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
||||||
size="small"
|
/>
|
||||||
type="number"
|
|
||||||
value={gift.sortOrder}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AdminFormSwitchField
|
|
||||||
checked={gift.enabled}
|
|
||||||
checkedLabel="启用"
|
|
||||||
className={styles.giftTierSwitch}
|
|
||||||
label={`第 ${index + 1} 档启用状态`}
|
|
||||||
uncheckedLabel="停用"
|
|
||||||
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -302,9 +288,6 @@ function configToForm(config) {
|
|||||||
.map((gift, index) => ({
|
.map((gift, index) => ({
|
||||||
enabled: gift.enabled !== false,
|
enabled: gift.enabled !== false,
|
||||||
giftId: gift.giftId || "",
|
giftId: gift.giftId || "",
|
||||||
giftIconUrl: gift.giftIconUrl || "",
|
|
||||||
giftName: gift.giftName || "",
|
|
||||||
giftPriceCoin: Number(gift.giftPriceCoin || 0),
|
|
||||||
sortOrder: gift.sortOrder || index + 1,
|
sortOrder: gift.sortOrder || index + 1,
|
||||||
}));
|
}));
|
||||||
while (stakeGifts.length < 4) {
|
while (stakeGifts.length < 4) {
|
||||||
@ -318,34 +301,6 @@ function configToForm(config) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function giftPatchFromSelected(giftId, gift) {
|
|
||||||
return {
|
|
||||||
giftId: String(giftId || "").trim(),
|
|
||||||
giftIconUrl: gift?.giftIconUrl || gift?.resource?.previewUrl || gift?.resource?.assetUrl || "",
|
|
||||||
giftName: gift?.name || gift?.giftName || gift?.resource?.name || "",
|
|
||||||
giftPriceCoin: Number(gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateStakeGifts(stakeGifts) {
|
|
||||||
if (!Array.isArray(stakeGifts) || stakeGifts.length !== 4) {
|
|
||||||
return "房内猜拳必须配置 4 个礼物档位";
|
|
||||||
}
|
|
||||||
const seen = new Set();
|
|
||||||
for (let index = 0; index < stakeGifts.length; index += 1) {
|
|
||||||
const gift = stakeGifts[index];
|
|
||||||
const giftId = String(gift?.giftId || "").trim();
|
|
||||||
if (!giftId) {
|
|
||||||
return `第 ${index + 1} 档礼物未选择`;
|
|
||||||
}
|
|
||||||
if (seen.has(giftId)) {
|
|
||||||
return `第 ${index + 1} 档礼物重复,请选择不同礼物`;
|
|
||||||
}
|
|
||||||
seen.add(giftId);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formToPayload(form) {
|
function formToPayload(form) {
|
||||||
return {
|
return {
|
||||||
status: form.status || "active",
|
status: form.status || "active",
|
||||||
@ -353,7 +308,7 @@ function formToPayload(form) {
|
|||||||
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
||||||
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
||||||
enabled: gift.enabled !== false,
|
enabled: gift.enabled !== false,
|
||||||
giftId: String(gift.giftId || "").trim(),
|
giftId: Number(gift.giftId || 0),
|
||||||
sortOrder: Number(gift.sortOrder || index + 1),
|
sortOrder: Number(gift.sortOrder || index + 1),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,7 +38,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("room rps gift tier uses gift preview card drawer selection before saving", async () => {
|
test("room rps gift tier uses shared gift drawer selection before saving", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -54,20 +54,19 @@ test("room rps gift tier uses gift preview card drawer selection before saving",
|
|||||||
await user.click(screen.getByRole("button", { name: "编辑" }));
|
await user.click(screen.getByRole("button", { name: "编辑" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
|
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /第 1 档礼物 Rock Rose/ }));
|
await user.click(screen.getByDisplayValue("Rock Rose"));
|
||||||
|
|
||||||
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
|
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
|
||||||
expect(screen.getByText("Invalid Alpha Gift")).toBeInTheDocument();
|
await user.click(screen.getByText("Lucky Star"));
|
||||||
await user.click(screen.getByText("Invalid Alpha Gift"));
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /第 1 档礼物 Invalid Alpha Gift/ })).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Lucky Star")).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "保存配置" }));
|
await user.click(screen.getByRole("button", { name: "保存配置" }));
|
||||||
|
|
||||||
await waitFor(() => expect(patchBodies).toHaveLength(1));
|
await waitFor(() => expect(patchBodies).toHaveLength(1));
|
||||||
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
giftId: "Gifi-3",
|
giftId: 10005,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -96,30 +95,8 @@ async function mockFetch(input, init = {}) {
|
|||||||
if (url.includes("/v1/admin/gifts")) {
|
if (url.includes("/v1/admin/gifts")) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
items: [
|
items: [
|
||||||
{
|
{ coinPrice: 10, giftId: "10001", giftTypeCode: "normal", name: "Rock Rose", resourceId: 11 },
|
||||||
coinPrice: 10,
|
{ coinPrice: 20, giftId: "10005", giftTypeCode: "normal", name: "Lucky Star", resourceId: 12 },
|
||||||
giftId: "10001",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Rock Rose",
|
|
||||||
resource: { previewUrl: "https://cdn.example.com/rock.webp", resourceCode: "rock_rose" },
|
|
||||||
resourceId: 11,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
coinPrice: 20,
|
|
||||||
giftId: "10005",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Lucky Star",
|
|
||||||
resource: { previewUrl: "https://cdn.example.com/lucky.webp", resourceCode: "lucky_star" },
|
|
||||||
resourceId: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
coinPrice: 1,
|
|
||||||
giftId: "Gifi-3",
|
|
||||||
giftTypeCode: "normal",
|
|
||||||
name: "Invalid Alpha Gift",
|
|
||||||
resource: { previewUrl: "https://cdn.example.com/invalid.webp", resourceCode: "invalid_alpha" },
|
|
||||||
resourceId: 13,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
|
|||||||
@ -1,24 +1,12 @@
|
|||||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
|
||||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
|
||||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import {
|
import { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
|
||||||
adjustSelfGameStakePool,
|
|
||||||
getSelfGameNewUserPolicy,
|
|
||||||
listSelfGameStakePools,
|
|
||||||
listSelfGames,
|
|
||||||
updateDiceConfig,
|
|
||||||
updateSelfGameNewUserPolicy,
|
|
||||||
updateSelfGameStakePool,
|
|
||||||
} from "@/features/games/api";
|
|
||||||
import styles from "@/features/games/games.module.css";
|
import styles from "@/features/games/games.module.css";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
@ -27,12 +15,7 @@ import {
|
|||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import {
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -49,37 +32,12 @@ const defaultConfigForm = {
|
|||||||
robotMatchWaitMs: 3000,
|
robotMatchWaitMs: 3000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultStakePoolConfigForm = {
|
const defaultPoolForm = {
|
||||||
stakeCoin: "",
|
|
||||||
targetBalanceCoin: "",
|
|
||||||
safeBalanceCoin: "",
|
|
||||||
warningBalanceCoin: "",
|
|
||||||
dangerBalanceCoin: "",
|
|
||||||
status: "active",
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultStakePoolAdjustForm = {
|
|
||||||
stakeCoin: "",
|
|
||||||
amountCoin: "",
|
amountCoin: "",
|
||||||
direction: "in",
|
direction: "in",
|
||||||
reason: "admin_adjust",
|
reason: "admin_adjust",
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPolicyForm = {
|
|
||||||
enabled: true,
|
|
||||||
protectionRounds: 30,
|
|
||||||
protectionHours: 168,
|
|
||||||
maxStakeCoin: 5000,
|
|
||||||
lifetimeSubsidyQuotaCoin: 30000,
|
|
||||||
day1SubsidyQuotaCoin: 15000,
|
|
||||||
singleRoundSubsidyCapCoin: 5000,
|
|
||||||
strategyLevel: "soft_boost",
|
|
||||||
loseStreakProtectionEnabled: true,
|
|
||||||
loseStreakTrigger: 2,
|
|
||||||
normalPhaseTargetWinRatePercent: 53,
|
|
||||||
blackPoolRobotForceWinEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SelfGamePage() {
|
export function SelfGamePage() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@ -90,10 +48,7 @@ export function SelfGamePage() {
|
|||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [activeGame, setActiveGame] = useState(null);
|
const [activeGame, setActiveGame] = useState(null);
|
||||||
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
||||||
const [stakePools, setStakePools] = useState([]);
|
const [poolForm, setPoolForm] = useState(defaultPoolForm);
|
||||||
const [stakePoolConfigForm, setStakePoolConfigForm] = useState(defaultStakePoolConfigForm);
|
|
||||||
const [stakePoolAdjustForm, setStakePoolAdjustForm] = useState(defaultStakePoolAdjustForm);
|
|
||||||
const [policyForm, setPolicyForm] = useState(defaultPolicyForm);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -111,74 +66,6 @@ export function SelfGamePage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const closeDialog = useCallback(() => {
|
|
||||||
setActiveAction("");
|
|
||||||
setActiveGame(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openConfig = useCallback((game) => {
|
|
||||||
setActiveGame(game);
|
|
||||||
setConfigForm(configToForm(game));
|
|
||||||
setActiveAction("config");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyStakePools = useCallback((pools, direction = "in") => {
|
|
||||||
const items = pools || [];
|
|
||||||
setStakePools(items);
|
|
||||||
const selected = items[0];
|
|
||||||
if (!selected) {
|
|
||||||
setStakePoolConfigForm(defaultStakePoolConfigForm);
|
|
||||||
setStakePoolAdjustForm({ ...defaultStakePoolAdjustForm, direction });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStakePoolConfigForm(stakePoolToConfigForm(selected));
|
|
||||||
setStakePoolAdjustForm(stakePoolToAdjustForm(selected, direction));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reloadStakePools = useCallback(
|
|
||||||
async (gameId, direction = stakePoolAdjustForm.direction || "in") => {
|
|
||||||
const result = await listSelfGameStakePools(gameId);
|
|
||||||
applyStakePools(result.items || [], direction);
|
|
||||||
},
|
|
||||||
[applyStakePools, stakePoolAdjustForm.direction],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openStakePools = useCallback(
|
|
||||||
async (game, direction = "in") => {
|
|
||||||
setActiveGame(game);
|
|
||||||
setActiveAction("stake-pools");
|
|
||||||
setLoadingAction("stake-pools-load");
|
|
||||||
try {
|
|
||||||
await reloadStakePools(game.gameId, direction);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "加载档位奖池失败", "error");
|
|
||||||
closeDialog();
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[closeDialog, reloadStakePools, showToast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openPolicy = useCallback(
|
|
||||||
async (game) => {
|
|
||||||
setActiveGame(game);
|
|
||||||
setPolicyForm({ ...defaultPolicyForm, gameId: game.gameId });
|
|
||||||
setActiveAction("policy");
|
|
||||||
setLoadingAction("policy-load");
|
|
||||||
try {
|
|
||||||
const policy = await getSelfGameNewUserPolicy(game.gameId);
|
|
||||||
setPolicyForm(policyToForm(policy, game.gameId));
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "加载新手策略失败", "error");
|
|
||||||
closeDialog();
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[closeDialog, showToast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -213,8 +100,8 @@ export function SelfGamePage() {
|
|||||||
{
|
{
|
||||||
key: "robot",
|
key: "robot",
|
||||||
label: "机器人",
|
label: "机器人",
|
||||||
width: "minmax(190px, .9fr)",
|
width: "minmax(150px, .8fr)",
|
||||||
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 策略引擎`,
|
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "updated",
|
key: "updated",
|
||||||
@ -225,36 +112,39 @@ export function SelfGamePage() {
|
|||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "232px",
|
width: "128px",
|
||||||
render: (game) => (
|
render: (game) => (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||||
<SettingsOutlined fontSize="small" />
|
<SettingsOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
<AdminActionIconButton disabled={!canUpdate} label="新手策略" onClick={() => openPolicy(game)}>
|
<AdminActionIconButton disabled={!canUpdate} label="调整奖池" onClick={() => openPool(game)}>
|
||||||
<TuneOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={!canUpdate}
|
|
||||||
label="档位奖池"
|
|
||||||
onClick={() => openStakePools(game, "in")}
|
|
||||||
>
|
|
||||||
<AccountBalanceWalletOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={!canUpdate}
|
|
||||||
label="快速加奖池"
|
|
||||||
onClick={() => openStakePools(game, "in")}
|
|
||||||
>
|
|
||||||
<AddCircleOutlineOutlined fontSize="small" />
|
<AddCircleOutlineOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[canUpdate, openConfig, openPolicy, openStakePools],
|
[canUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openConfig = (game) => {
|
||||||
|
setActiveGame(game);
|
||||||
|
setConfigForm(configToForm(game));
|
||||||
|
setActiveAction("config");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPool = (game) => {
|
||||||
|
setActiveGame(game);
|
||||||
|
setPoolForm(defaultPoolForm);
|
||||||
|
setActiveAction("pool");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setActiveAction("");
|
||||||
|
setActiveGame(null);
|
||||||
|
};
|
||||||
|
|
||||||
const submitConfig = async (event) => {
|
const submitConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeGame) return;
|
if (!activeGame) return;
|
||||||
@ -271,57 +161,21 @@ export function SelfGamePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitStakePoolConfig = async (event) => {
|
const submitPool = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeGame) return;
|
if (!activeGame) return;
|
||||||
setLoadingAction("stake-pool-config");
|
setLoadingAction("pool");
|
||||||
try {
|
try {
|
||||||
await updateSelfGameStakePool(
|
await adjustDicePool(activeGame.gameId, {
|
||||||
activeGame.gameId,
|
amountCoin: Number(poolForm.amountCoin || 0),
|
||||||
Number(stakePoolConfigForm.stakeCoin || 0),
|
direction: poolForm.direction,
|
||||||
stakePoolConfigPayload(stakePoolConfigForm),
|
reason: poolForm.reason,
|
||||||
);
|
|
||||||
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
|
||||||
showToast("档位奖池配置已保存", "success");
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "保存档位奖池失败", "error");
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitStakePoolAdjust = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!activeGame) return;
|
|
||||||
setLoadingAction("stake-pool-adjust");
|
|
||||||
try {
|
|
||||||
await adjustSelfGameStakePool(activeGame.gameId, Number(stakePoolAdjustForm.stakeCoin || 0), {
|
|
||||||
amountCoin: Number(stakePoolAdjustForm.amountCoin || 0),
|
|
||||||
direction: stakePoolAdjustForm.direction,
|
|
||||||
reason: stakePoolAdjustForm.reason,
|
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
showToast("奖池已调整", "success");
|
||||||
setStakePoolAdjustForm((current) => ({ ...current, amountCoin: "" }));
|
|
||||||
showToast(stakePoolAdjustForm.direction === "out" ? "档位奖池已扣除" : "档位奖池已增加", "success");
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "调整档位奖池失败", "error");
|
|
||||||
} finally {
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitPolicy = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!activeGame) return;
|
|
||||||
setLoadingAction("policy");
|
|
||||||
try {
|
|
||||||
await updateSelfGameNewUserPolicy(activeGame.gameId, policyPayload(policyForm, activeGame.gameId));
|
|
||||||
await load();
|
|
||||||
showToast("新手策略已保存", "success");
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存新手策略失败", "error");
|
showToast(err.message || "调整奖池失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
@ -351,27 +205,13 @@ export function SelfGamePage() {
|
|||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
onSubmit={submitConfig}
|
onSubmit={submitConfig}
|
||||||
/>
|
/>
|
||||||
<StakePoolsDialog
|
<PoolDialog
|
||||||
adjustForm={stakePoolAdjustForm}
|
form={poolForm}
|
||||||
configForm={stakePoolConfigForm}
|
loading={loadingAction === "pool"}
|
||||||
loading={loadingAction === "stake-pools-load" || loadingAction === "stake-pool-config"}
|
open={activeAction === "pool"}
|
||||||
pools={stakePools}
|
setForm={setPoolForm}
|
||||||
setAdjustForm={setStakePoolAdjustForm}
|
|
||||||
setConfigForm={setStakePoolConfigForm}
|
|
||||||
submittingConfig={loadingAction === "stake-pool-config"}
|
|
||||||
submittingAdjust={loadingAction === "stake-pool-adjust"}
|
|
||||||
open={activeAction === "stake-pools"}
|
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
onSubmitAdjust={submitStakePoolAdjust}
|
onSubmit={submitPool}
|
||||||
onSubmitConfig={submitStakePoolConfig}
|
|
||||||
/>
|
|
||||||
<PolicyDialog
|
|
||||||
form={policyForm}
|
|
||||||
loading={loadingAction === "policy" || loadingAction === "policy-load"}
|
|
||||||
open={activeAction === "policy"}
|
|
||||||
setForm={setPolicyForm}
|
|
||||||
onClose={closeDialog}
|
|
||||||
onSubmit={submitPolicy}
|
|
||||||
/>
|
/>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
@ -379,14 +219,7 @@ export function SelfGamePage() {
|
|||||||
|
|
||||||
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog loading={loading} open={open} submitLabel="保存配置" title="骰子配置" onClose={onClose} onSubmit={onSubmit}>
|
||||||
loading={loading}
|
|
||||||
open={open}
|
|
||||||
submitLabel="保存配置"
|
|
||||||
title="骰子配置"
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
<AdminFormSection title="下注与费率">
|
<AdminFormSection title="下注与费率">
|
||||||
<AdminFormFieldGrid>
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<TextField
|
||||||
@ -440,7 +273,7 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
<AdminFormSwitchField
|
<AdminFormSwitchField
|
||||||
checked={form.robotEnabled}
|
checked={form.robotEnabled}
|
||||||
label="机器人补位"
|
label="机器人补位"
|
||||||
onChange={(checked) => setForm({ ...form, robotEnabled: checked })}
|
onChange={(event) => setForm({ ...form, robotEnabled: event.target.checked })}
|
||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
@ -448,333 +281,31 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StakePoolsDialog({
|
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||||
adjustForm,
|
|
||||||
configForm,
|
|
||||||
loading,
|
|
||||||
open,
|
|
||||||
pools,
|
|
||||||
setAdjustForm,
|
|
||||||
setConfigForm,
|
|
||||||
submittingAdjust,
|
|
||||||
submittingConfig,
|
|
||||||
onClose,
|
|
||||||
onSubmitAdjust,
|
|
||||||
onSubmitConfig,
|
|
||||||
}) {
|
|
||||||
const selectedStakeCoin = Number(configForm.stakeCoin || adjustForm.stakeCoin || 0);
|
|
||||||
const selectedPool = pools.find((pool) => pool.stakeCoin === selectedStakeCoin) || pools[0];
|
|
||||||
const isDeduct = adjustForm.direction === "out";
|
|
||||||
const selectPool = (pool, direction = adjustForm.direction || "in") => {
|
|
||||||
setConfigForm(stakePoolToConfigForm(pool));
|
|
||||||
setAdjustForm(stakePoolToAdjustForm(pool, direction));
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog
|
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
|
||||||
loading={loading}
|
<AdminFormFieldGrid>
|
||||||
open={open}
|
<TextField
|
||||||
size="wide"
|
label="方向"
|
||||||
submitDisabled={loading || submittingConfig || !configForm.stakeCoin}
|
select
|
||||||
submitLabel="保存档位配置"
|
value={form.direction}
|
||||||
title="档位奖池"
|
onChange={(event) => setForm({ ...form, direction: event.target.value })}
|
||||||
onClose={onClose}
|
>
|
||||||
onSubmit={onSubmitConfig}
|
<MenuItem value="in">增加</MenuItem>
|
||||||
>
|
<MenuItem value="out">减少</MenuItem>
|
||||||
<AdminFormSection title="档位水位">
|
</TextField>
|
||||||
<div className={styles.stakePoolList}>
|
<TextField
|
||||||
{pools.length ? (
|
label="金币"
|
||||||
pools.map((pool) => (
|
type="number"
|
||||||
<button
|
value={form.amountCoin}
|
||||||
key={pool.stakeCoin}
|
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
||||||
className={[
|
/>
|
||||||
styles.stakePoolRow,
|
<TextField
|
||||||
pool.stakeCoin === selectedStakeCoin ? styles.stakePoolRowActive : "",
|
label="原因"
|
||||||
]
|
value={form.reason}
|
||||||
.filter(Boolean)
|
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||||
.join(" ")}
|
/>
|
||||||
type="button"
|
</AdminFormFieldGrid>
|
||||||
onClick={() => selectPool(pool)}
|
|
||||||
>
|
|
||||||
<span className={styles.stakePoolMain}>
|
|
||||||
<span className={styles.stakePoolStake}>{formatNumber(pool.stakeCoin)}</span>
|
|
||||||
<span className={poolLevelClass(pool.poolLevel)}>
|
|
||||||
{poolLevelText(pool.poolLevel)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className={styles.stakePoolMetrics}>
|
|
||||||
余额 {formatNumber(pool.balanceCoin)} · 可用 {formatNumber(pool.availableCoin)} ·
|
|
||||||
冻结 {formatNumber(pool.reservedCoin)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.stakePoolMetrics}>
|
|
||||||
单局需 {formatNumber(pool.requiredPoolCoin)} · 可承受{" "}
|
|
||||||
{formatNumber(pool.humanWinCapacity)} 次 ·{" "}
|
|
||||||
{pool.botMatchEnabled ? "可补机器人" : "暂停补机器人"}
|
|
||||||
</span>
|
|
||||||
{pool.poolLevel === "black" ? (
|
|
||||||
<span className={styles.stakePoolWarning}>
|
|
||||||
黑色原因:{blackReasonText(pool.blackReason)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className={styles.stakePoolEmpty}>暂无档位奖池</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="水位阈值">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<TextField disabled label="当前档位" value={formatNumber(configForm.stakeCoin)} />
|
|
||||||
<TextField
|
|
||||||
label="状态"
|
|
||||||
select
|
|
||||||
value={configForm.status}
|
|
||||||
onChange={(event) => setConfigForm({ ...configForm, status: event.target.value })}
|
|
||||||
>
|
|
||||||
<MenuItem value="active">启用</MenuItem>
|
|
||||||
<MenuItem value="disabled">停用</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
label="目标奖池"
|
|
||||||
type="number"
|
|
||||||
value={configForm.targetBalanceCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
onChange={(event) => setConfigForm({ ...configForm, targetBalanceCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="绿色阈值"
|
|
||||||
type="number"
|
|
||||||
value={configForm.safeBalanceCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
onChange={(event) => setConfigForm({ ...configForm, safeBalanceCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="黄色阈值"
|
|
||||||
type="number"
|
|
||||||
value={configForm.warningBalanceCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
onChange={(event) => setConfigForm({ ...configForm, warningBalanceCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="红色阈值"
|
|
||||||
type="number"
|
|
||||||
value={configForm.dangerBalanceCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 0 } }}
|
|
||||||
onChange={(event) => setConfigForm({ ...configForm, dangerBalanceCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
{selectedPool ? (
|
|
||||||
<div className={styles.stakePoolHint}>
|
|
||||||
黑色水位自动判断:状态停用,或可用余额低于单局需 {formatNumber(selectedPool.requiredPoolCoin)}。
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="档位奖池加扣">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<TextField disabled label="当前档位" value={formatNumber(adjustForm.stakeCoin)} />
|
|
||||||
<TextField
|
|
||||||
label="方向"
|
|
||||||
select
|
|
||||||
value={adjustForm.direction}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAdjustForm({
|
|
||||||
...adjustForm,
|
|
||||||
direction: event.target.value,
|
|
||||||
reason: event.target.value === "out" ? "admin_deduct" : "admin_adjust",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem value="in">增加</MenuItem>
|
|
||||||
<MenuItem value="out">扣除</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
|
||||||
helperText={isDeduct ? "不能扣成负数" : ""}
|
|
||||||
label="金币"
|
|
||||||
type="number"
|
|
||||||
value={adjustForm.amountCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setAdjustForm({ ...adjustForm, amountCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="原因"
|
|
||||||
value={adjustForm.reason}
|
|
||||||
onChange={(event) => setAdjustForm({ ...adjustForm, reason: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
<div className={styles.stakePoolInlineActions}>
|
|
||||||
<button
|
|
||||||
className={styles.stakePoolPrimaryButton}
|
|
||||||
disabled={loading || submittingAdjust || !adjustForm.stakeCoin || !adjustForm.amountCoin}
|
|
||||||
type="button"
|
|
||||||
onClick={onSubmitAdjust}
|
|
||||||
>
|
|
||||||
{isDeduct ? "确认扣除" : "确认增加"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</AdminFormSection>
|
|
||||||
</AdminFormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StrategyLevelLabel() {
|
|
||||||
return (
|
|
||||||
<span className={styles.strategyLevelLabel}>
|
|
||||||
<span>策略等级</span>
|
|
||||||
<HelpOutlineOutlined className={styles.strategyLevelHelpIcon} fontSize="inherit" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StrategyLevelTooltip() {
|
|
||||||
return (
|
|
||||||
<div className={styles.strategyLevelTooltip}>
|
|
||||||
<div className={styles.strategyLevelTooltipTitle}>只影响新手期机器人补位局</div>
|
|
||||||
<div>
|
|
||||||
低风险、绿色水位、下注 1000/5000 且额度足够时:柔性激励按第 1 局 70%、第 2-6 局 60%、第 7-16 局 55%、第
|
|
||||||
17-30 局 50%、第 31 局后正常随机;强激励对应 85%、75%、65%、55%、50%。
|
|
||||||
</div>
|
|
||||||
<ul className={styles.strategyLevelTooltipList}>
|
|
||||||
<li>关闭保护:不强制用户赢,全部走 dice/rock 正常随机。</li>
|
|
||||||
<li>首局不输:第 1 个有效机器人局 100% 正反馈,后续按柔性激励曲线。</li>
|
|
||||||
<li>柔性激励案例:第 3 个有效机器人局命中率 60%;rock 平局不计有效局,也不消耗额度。</li>
|
|
||||||
<li>强激励案例:第 10 个有效机器人局命中率 65%;第 20 个有效机器人局命中率 55%。</li>
|
|
||||||
<li>黄色水位案例:柔性第 1 局 70% 降到 60%,强激励第 1 局 85% 降到 75%。</li>
|
|
||||||
<li>风控案例:risk_score 30-49 降 10%,50-69 降 25%,70 及以上不享受保护。</li>
|
|
||||||
<li>额度案例:默认最大下注 5000,40000/100000 档不触发;首日额度不足时降级正常随机。</li>
|
|
||||||
<li>自然期案例:配置 50% 就完全自然随机;配置 53% 时 dice 约 6% 的机器人局补正为用户赢,rock 约 4.1%。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PolicyDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|
||||||
return (
|
|
||||||
<AdminFormDialog
|
|
||||||
loading={loading}
|
|
||||||
open={open}
|
|
||||||
submitLabel="保存策略"
|
|
||||||
title="新手策略"
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
<AdminFormSection title="保护窗口">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<AdminFormSwitchField
|
|
||||||
checked={form.enabled}
|
|
||||||
label="启用"
|
|
||||||
onChange={(checked) => setForm({ ...form, enabled: checked })}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
arrow
|
|
||||||
placement="left"
|
|
||||||
title={<StrategyLevelTooltip />}
|
|
||||||
slotProps={{ tooltip: { className: styles.strategyLevelTooltipPaper } }}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label={<StrategyLevelLabel />}
|
|
||||||
select
|
|
||||||
value={form.strategyLevel}
|
|
||||||
onChange={(event) => setForm({ ...form, strategyLevel: event.target.value })}
|
|
||||||
>
|
|
||||||
<MenuItem value="none">关闭保护</MenuItem>
|
|
||||||
<MenuItem value="first_no_lose">首局不输</MenuItem>
|
|
||||||
<MenuItem value="soft_boost">柔性激励</MenuItem>
|
|
||||||
<MenuItem value="strong_boost">强激励</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Tooltip>
|
|
||||||
<TextField
|
|
||||||
label="保护局数"
|
|
||||||
type="number"
|
|
||||||
value={form.protectionRounds}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, protectionRounds: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="保护小时"
|
|
||||||
type="number"
|
|
||||||
value={form.protectionHours}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, protectionHours: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="补贴额度">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<TextField
|
|
||||||
label="最大下注"
|
|
||||||
type="number"
|
|
||||||
value={form.maxStakeCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, maxStakeCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="生命周期额度"
|
|
||||||
type="number"
|
|
||||||
value={form.lifetimeSubsidyQuotaCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, lifetimeSubsidyQuotaCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="首日额度"
|
|
||||||
type="number"
|
|
||||||
value={form.day1SubsidyQuotaCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, day1SubsidyQuotaCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="单局上限"
|
|
||||||
type="number"
|
|
||||||
value={form.singleRoundSubsidyCapCoin}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, singleRoundSubsidyCapCoin: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="连输保护">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<AdminFormSwitchField
|
|
||||||
checked={form.loseStreakProtectionEnabled}
|
|
||||||
label="启用"
|
|
||||||
onChange={(checked) => setForm({ ...form, loseStreakProtectionEnabled: checked })}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="连输阈值"
|
|
||||||
type="number"
|
|
||||||
value={form.loseStreakTrigger}
|
|
||||||
slotProps={{ htmlInput: { min: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, loseStreakTrigger: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="成熟期体验">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<Tooltip
|
|
||||||
arrow
|
|
||||||
placement="left"
|
|
||||||
title="只影响已过新手保护候选条件的机器人补位局。50% 表示完全自然随机;高于 50% 会换算成少量用户赢补正,不会强制用户输,也不消耗新手补贴额度。"
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
helperText="建议 53,用来抵消 5% 平台费和 1% 入池带来的长期负期望"
|
|
||||||
label="自然期目标胜率 %"
|
|
||||||
type="number"
|
|
||||||
value={form.normalPhaseTargetWinRatePercent}
|
|
||||||
slotProps={{ htmlInput: { min: 50, max: 100, step: 1 } }}
|
|
||||||
onChange={(event) => setForm({ ...form, normalPhaseTargetWinRatePercent: event.target.value })}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="黑色水位">
|
|
||||||
<AdminFormFieldGrid>
|
|
||||||
<Tooltip arrow placement="left" title="关闭时黑色档位只等待真人;开启时黑色档位允许机器人补位,并强制机器人获胜。">
|
|
||||||
<AdminFormSwitchField
|
|
||||||
checked={form.blackPoolRobotForceWinEnabled}
|
|
||||||
label="机器人强制获胜"
|
|
||||||
onChange={(checked) => setForm({ ...form, blackPoolRobotForceWinEnabled: checked })}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -806,94 +337,6 @@ function configPayload(form, gameId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function policyToForm(policy, gameId) {
|
|
||||||
return {
|
|
||||||
...defaultPolicyForm,
|
|
||||||
...policy,
|
|
||||||
gameId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function policyPayload(form, gameId) {
|
|
||||||
return {
|
|
||||||
gameId,
|
|
||||||
enabled: Boolean(form.enabled),
|
|
||||||
protectionRounds: Number(form.protectionRounds || 30),
|
|
||||||
protectionHours: Number(form.protectionHours || 168),
|
|
||||||
maxStakeCoin: Number(form.maxStakeCoin || 5000),
|
|
||||||
lifetimeSubsidyQuotaCoin: Number(form.lifetimeSubsidyQuotaCoin || 30000),
|
|
||||||
day1SubsidyQuotaCoin: Number(form.day1SubsidyQuotaCoin || 15000),
|
|
||||||
singleRoundSubsidyCapCoin: Number(form.singleRoundSubsidyCapCoin || 5000),
|
|
||||||
strategyLevel: form.strategyLevel || "soft_boost",
|
|
||||||
loseStreakProtectionEnabled: Boolean(form.loseStreakProtectionEnabled),
|
|
||||||
loseStreakTrigger: Number(form.loseStreakTrigger || 2),
|
|
||||||
normalPhaseTargetWinRatePercent: Number(form.normalPhaseTargetWinRatePercent || 53),
|
|
||||||
blackPoolRobotForceWinEnabled: Boolean(form.blackPoolRobotForceWinEnabled),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stakePoolToConfigForm(pool) {
|
|
||||||
if (!pool) return defaultStakePoolConfigForm;
|
|
||||||
return {
|
|
||||||
stakeCoin: pool.stakeCoin,
|
|
||||||
targetBalanceCoin: pool.targetBalanceCoin,
|
|
||||||
safeBalanceCoin: pool.safeBalanceCoin,
|
|
||||||
warningBalanceCoin: pool.warningBalanceCoin,
|
|
||||||
dangerBalanceCoin: pool.dangerBalanceCoin,
|
|
||||||
status: pool.status || "active",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stakePoolToAdjustForm(pool, direction = "in") {
|
|
||||||
if (!pool) return { ...defaultStakePoolAdjustForm, direction };
|
|
||||||
return {
|
|
||||||
...defaultStakePoolAdjustForm,
|
|
||||||
stakeCoin: pool.stakeCoin,
|
|
||||||
direction,
|
|
||||||
reason: direction === "out" ? "admin_deduct" : "admin_adjust",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stakePoolConfigPayload(form) {
|
|
||||||
return {
|
|
||||||
targetBalanceCoin: Number(form.targetBalanceCoin || 0),
|
|
||||||
safeBalanceCoin: Number(form.safeBalanceCoin || 0),
|
|
||||||
warningBalanceCoin: Number(form.warningBalanceCoin || 0),
|
|
||||||
dangerBalanceCoin: Number(form.dangerBalanceCoin || 0),
|
|
||||||
status: form.status || "active",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolLevelText(level) {
|
|
||||||
switch (level) {
|
|
||||||
case "green":
|
|
||||||
return "绿色";
|
|
||||||
case "yellow":
|
|
||||||
return "黄色";
|
|
||||||
case "red":
|
|
||||||
return "红色";
|
|
||||||
case "black":
|
|
||||||
return "黑色";
|
|
||||||
default:
|
|
||||||
return level || "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolLevelClass(level) {
|
|
||||||
return [styles.poolLevelBadge, styles[`poolLevel_${level || "unknown"}`]].filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function blackReasonText(reason) {
|
|
||||||
switch (reason) {
|
|
||||||
case "status_disabled":
|
|
||||||
return "档位奖池已停用";
|
|
||||||
case "available_below_required_pool_coin":
|
|
||||||
return "可用余额不足以覆盖真人赢机器人净支出";
|
|
||||||
default:
|
|
||||||
return reason || "可用余额不足";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStakeOptions(text) {
|
function parseStakeOptions(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/[,,\s]+/)
|
.split(/[,,\s]+/)
|
||||||
|
|||||||
@ -221,8 +221,6 @@ export interface HostSalarySettlementDto {
|
|||||||
commandId: string;
|
commandId: string;
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
settlementType: string;
|
settlementType: string;
|
||||||
triggerMode: string;
|
|
||||||
settlementRole: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
user: HostSalarySettlementUserDto;
|
user: HostSalarySettlementUserDto;
|
||||||
agencyOwnerUserId: string;
|
agencyOwnerUserId: string;
|
||||||
@ -243,75 +241,7 @@ export interface HostSalarySettlementDto {
|
|||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostSalarySettlementPendingDto {
|
|
||||||
userId: string;
|
|
||||||
user: HostSalarySettlementUserDto;
|
|
||||||
agencyOwnerUserId: string;
|
|
||||||
agencyOwner: HostSalarySettlementUserDto;
|
|
||||||
role: string;
|
|
||||||
cycleKey: string;
|
|
||||||
regionId: number;
|
|
||||||
regionName?: string;
|
|
||||||
policyId: number;
|
|
||||||
policyName: string;
|
|
||||||
settlementType: string;
|
|
||||||
triggerMode: string;
|
|
||||||
levelNo: number;
|
|
||||||
requiredDiamonds: number;
|
|
||||||
totalDiamonds: number;
|
|
||||||
hostSalaryUsdMinorTarget: number;
|
|
||||||
hostSalaryUsdTarget: string;
|
|
||||||
hostSalaryUsdMinorDelta: number;
|
|
||||||
hostSalaryUsdDelta: string;
|
|
||||||
hostCoinRewardTarget: number;
|
|
||||||
hostCoinRewardDelta: number;
|
|
||||||
agencySalaryUsdMinorTarget: number;
|
|
||||||
agencySalaryUsdTarget: string;
|
|
||||||
agencySalaryUsdMinorDelta: number;
|
|
||||||
agencySalaryUsdDelta: string;
|
|
||||||
residualUsdMinorDelta: number;
|
|
||||||
residualUsdDelta: string;
|
|
||||||
totalUsdMinorDelta: number;
|
|
||||||
totalUsdDelta: string;
|
|
||||||
settledLevelNo: number;
|
|
||||||
settledHostSalaryUsdMinor: number;
|
|
||||||
settledHostSalaryUsd: string;
|
|
||||||
settledHostCoinReward: number;
|
|
||||||
settledAgencySalaryUsdMinor: number;
|
|
||||||
settledAgencySalaryUsd: string;
|
|
||||||
lastSettledTotalDiamonds: number;
|
|
||||||
residualUsdMinor: number;
|
|
||||||
residualUsd: string;
|
|
||||||
monthEndClearedAtMs: number;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostSalarySettlePayload {
|
|
||||||
role: "host" | "agency";
|
|
||||||
settlement_type: "daily" | "half_month" | "month_end";
|
|
||||||
trigger_mode: "manual";
|
|
||||||
cycle_key: string;
|
|
||||||
region_id?: number;
|
|
||||||
country_code?: string;
|
|
||||||
user_ids?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostSalarySettleResult {
|
|
||||||
role: string;
|
|
||||||
cycleKey: string;
|
|
||||||
settlementType: string;
|
|
||||||
triggerMode: string;
|
|
||||||
requestedCount: number;
|
|
||||||
processedCount: number;
|
|
||||||
successCount: number;
|
|
||||||
skippedCount: number;
|
|
||||||
failureCount: number;
|
|
||||||
records: HostSalarySettlementDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
|
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
|
||||||
type RawHostPending = HostSalarySettlementPendingDto & Record<string, unknown>;
|
|
||||||
type RawHostSettleResult = HostSalarySettleResult & Record<string, unknown>;
|
|
||||||
|
|
||||||
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
|
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
|
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
|
||||||
@ -324,25 +254,6 @@ export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPag
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listHostSalarySettlementPending(
|
|
||||||
query: PageQuery = {},
|
|
||||||
): Promise<ApiPage<HostSalarySettlementPendingDto>> {
|
|
||||||
return apiRequest<ApiPage<RawHostPending>>("/v1/admin/host-salary-settlements/pending", {
|
|
||||||
method: "GET",
|
|
||||||
query,
|
|
||||||
}).then((page) => ({
|
|
||||||
...page,
|
|
||||||
items: (page.items || []).map(normalizeHostPending),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function settleHostSalary(payload: HostSalarySettlePayload): Promise<HostSalarySettleResult> {
|
|
||||||
return apiRequest<RawHostSettleResult, HostSalarySettlePayload>("/v1/admin/host-salary-settlements/settle", {
|
|
||||||
body: payload,
|
|
||||||
method: "POST",
|
|
||||||
}).then(normalizeHostSettleResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeamSalarySettlementPendingDto {
|
export interface TeamSalarySettlementPendingDto {
|
||||||
policyType: "bd" | "admin";
|
policyType: "bd" | "admin";
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -394,7 +305,7 @@ export interface TeamSalarySettlePayload {
|
|||||||
cycle_key: string;
|
cycle_key: string;
|
||||||
region_id?: number;
|
region_id?: number;
|
||||||
country_code?: string;
|
country_code?: string;
|
||||||
user_ids?: string[];
|
user_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamSalarySettleResult {
|
export interface TeamSalarySettleResult {
|
||||||
@ -481,8 +392,6 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
|||||||
commandId: stringValue(raw.commandId ?? raw.command_id),
|
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||||
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
||||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "automatic",
|
|
||||||
settlementRole: stringValue(raw.settlementRole ?? raw.settlement_role) || "all",
|
|
||||||
userId: stringValue(raw.userId ?? raw.user_id),
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
user: normalizeSettlementUser(user),
|
user: normalizeSettlementUser(user),
|
||||||
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
||||||
@ -504,55 +413,6 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHostPending(raw: RawHostPending): HostSalarySettlementPendingDto {
|
|
||||||
const user = asRecord(raw.user);
|
|
||||||
const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner);
|
|
||||||
return {
|
|
||||||
userId: stringValue(raw.userId ?? raw.user_id),
|
|
||||||
user: normalizeSettlementUser(user),
|
|
||||||
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
|
||||||
agencyOwner: normalizeSettlementUser(agencyOwner),
|
|
||||||
role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role) || "host",
|
|
||||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
|
||||||
regionId: numberValue(raw.regionId ?? raw.region_id),
|
|
||||||
regionName: stringValue(raw.regionName ?? raw.region_name),
|
|
||||||
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
|
||||||
policyName: stringValue(raw.policyName ?? raw.policy_name),
|
|
||||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type) || "half_month",
|
|
||||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "manual",
|
|
||||||
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
|
||||||
requiredDiamonds: numberValue(raw.requiredDiamonds ?? raw.required_diamonds),
|
|
||||||
totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds),
|
|
||||||
hostSalaryUsdMinorTarget: numberValue(raw.hostSalaryUsdMinorTarget ?? raw.host_salary_usd_minor_target),
|
|
||||||
hostSalaryUsdTarget: stringValue(raw.hostSalaryUsdTarget ?? raw.host_salary_usd_target),
|
|
||||||
hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta),
|
|
||||||
hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta),
|
|
||||||
hostCoinRewardTarget: numberValue(raw.hostCoinRewardTarget ?? raw.host_coin_reward_target),
|
|
||||||
hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta),
|
|
||||||
agencySalaryUsdMinorTarget: numberValue(raw.agencySalaryUsdMinorTarget ?? raw.agency_salary_usd_minor_target),
|
|
||||||
agencySalaryUsdTarget: stringValue(raw.agencySalaryUsdTarget ?? raw.agency_salary_usd_target),
|
|
||||||
agencySalaryUsdMinorDelta: numberValue(raw.agencySalaryUsdMinorDelta ?? raw.agency_salary_usd_minor_delta),
|
|
||||||
agencySalaryUsdDelta: stringValue(raw.agencySalaryUsdDelta ?? raw.agency_salary_usd_delta),
|
|
||||||
residualUsdMinorDelta: numberValue(raw.residualUsdMinorDelta ?? raw.residual_usd_minor_delta),
|
|
||||||
residualUsdDelta: stringValue(raw.residualUsdDelta ?? raw.residual_usd_delta),
|
|
||||||
totalUsdMinorDelta: numberValue(raw.totalUsdMinorDelta ?? raw.total_usd_minor_delta),
|
|
||||||
totalUsdDelta: stringValue(raw.totalUsdDelta ?? raw.total_usd_delta),
|
|
||||||
settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no),
|
|
||||||
settledHostSalaryUsdMinor: numberValue(raw.settledHostSalaryUsdMinor ?? raw.settled_host_salary_usd_minor),
|
|
||||||
settledHostSalaryUsd: stringValue(raw.settledHostSalaryUsd ?? raw.settled_host_salary_usd),
|
|
||||||
settledHostCoinReward: numberValue(raw.settledHostCoinReward ?? raw.settled_host_coin_reward),
|
|
||||||
settledAgencySalaryUsdMinor: numberValue(
|
|
||||||
raw.settledAgencySalaryUsdMinor ?? raw.settled_agency_salary_usd_minor,
|
|
||||||
),
|
|
||||||
settledAgencySalaryUsd: stringValue(raw.settledAgencySalaryUsd ?? raw.settled_agency_salary_usd),
|
|
||||||
lastSettledTotalDiamonds: numberValue(raw.lastSettledTotalDiamonds ?? raw.last_settled_total_diamonds),
|
|
||||||
residualUsdMinor: numberValue(raw.residualUsdMinor ?? raw.residual_usd_minor),
|
|
||||||
residualUsd: stringValue(raw.residualUsd ?? raw.residual_usd),
|
|
||||||
monthEndClearedAtMs: numberValue(raw.monthEndClearedAtMs ?? raw.month_end_cleared_at_ms),
|
|
||||||
status: stringValue(raw.status) || "pending",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
|
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
|
||||||
return {
|
return {
|
||||||
userId: stringValue(raw.userId ?? raw.user_id),
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
@ -631,21 +491,6 @@ function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleRe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHostSettleResult(raw: RawHostSettleResult): HostSalarySettleResult {
|
|
||||||
return {
|
|
||||||
role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role),
|
|
||||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
|
||||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
|
||||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode),
|
|
||||||
requestedCount: numberValue(raw.requestedCount ?? raw.requested_count),
|
|
||||||
processedCount: numberValue(raw.processedCount ?? raw.processed_count),
|
|
||||||
successCount: numberValue(raw.successCount ?? raw.success_count),
|
|
||||||
skippedCount: numberValue(raw.skippedCount ?? raw.skipped_count),
|
|
||||||
failureCount: numberValue(raw.failureCount ?? raw.failure_count),
|
|
||||||
records: arrayValue(raw.records).map((item) => normalizeSettlement(asRecord(item) as RawSettlement)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
|
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
|
||||||
return {
|
return {
|
||||||
id: numberValue(item.id),
|
id: numberValue(item.id),
|
||||||
|
|||||||
@ -5,15 +5,11 @@ import Tabs from "@mui/material/Tabs";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
listHostSalarySettlements,
|
listHostSalarySettlements,
|
||||||
listHostSalarySettlementPending,
|
|
||||||
listTeamSalarySettlementPending,
|
listTeamSalarySettlementPending,
|
||||||
listTeamSalarySettlementRecords,
|
listTeamSalarySettlementRecords,
|
||||||
settleHostSalary,
|
|
||||||
settleTeamSalary,
|
settleTeamSalary,
|
||||||
} from "@/features/host-agency-policy/api";
|
} from "@/features/host-agency-policy/api";
|
||||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
|
||||||
import {
|
import {
|
||||||
AdminFilterResetButton,
|
AdminFilterResetButton,
|
||||||
AdminFilterSelect,
|
AdminFilterSelect,
|
||||||
@ -55,13 +51,6 @@ const policyTypeOptions = [
|
|||||||
|
|
||||||
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
|
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
|
||||||
|
|
||||||
const pendingRoleOptions = [
|
|
||||||
["bd", "BD"],
|
|
||||||
["admin", "Admin"],
|
|
||||||
["host", "Host"],
|
|
||||||
["agency", "Agency"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const triggerOptions = [
|
const triggerOptions = [
|
||||||
["automatic", "自动政策"],
|
["automatic", "自动政策"],
|
||||||
["manual", "手动政策"],
|
["manual", "手动政策"],
|
||||||
@ -70,18 +59,18 @@ const triggerOptions = [
|
|||||||
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
|
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
|
||||||
|
|
||||||
export function HostSalarySettlementPage() {
|
export function HostSalarySettlementPage() {
|
||||||
const [activeTab, setActiveTab] = useState("pending");
|
const [activeTab, setActiveTab] = useState("team-pending");
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<div className={styles.policyTabsBar}>
|
<div className={styles.policyTabsBar}>
|
||||||
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||||
<Tab className={styles.policyTab} label="待结算" value="pending" />
|
<Tab className={styles.policyTab} label="待结算" value="team-pending" />
|
||||||
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
|
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
|
||||||
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
|
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "pending" ? (
|
{activeTab === "team-pending" ? (
|
||||||
<PendingPanel />
|
<TeamPendingPanel />
|
||||||
) : activeTab === "team-records" ? (
|
) : activeTab === "team-records" ? (
|
||||||
<TeamRecordsPanel />
|
<TeamRecordsPanel />
|
||||||
) : (
|
) : (
|
||||||
@ -91,221 +80,6 @@ export function HostSalarySettlementPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PendingPanel() {
|
|
||||||
const [role, setRole] = useState("bd");
|
|
||||||
if (role === "host" || role === "agency") {
|
|
||||||
return <HostPendingPanel role={role} onRoleChange={setRole} />;
|
|
||||||
}
|
|
||||||
return <TeamPendingPanel role={role} onRoleChange={setRole} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostPendingPanel 展示 Host/Agency 动态账单;列表查询不入账,点击结算后才让 wallet-service 写钱包和记录。
|
|
||||||
function HostPendingPanel({ onRoleChange, role }) {
|
|
||||||
const abilities = useHostAgencyPolicyAbilities();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [settlementType, setSettlementType] = useState("half_month");
|
|
||||||
const [cycleKey, setCycleKey] = useState(currentCycleKey);
|
|
||||||
const [countryCode, setCountryCode] = useState("");
|
|
||||||
const [regionId, setRegionId] = useState("");
|
|
||||||
const [selected, setSelected] = useState(() => new Set());
|
|
||||||
const [settling, setSettling] = useState(false);
|
|
||||||
|
|
||||||
const filters = useMemo(
|
|
||||||
() => ({
|
|
||||||
country_code: countryCode,
|
|
||||||
cycle_key: cycleKey,
|
|
||||||
region_id: regionId,
|
|
||||||
role,
|
|
||||||
settlement_type: settlementType,
|
|
||||||
trigger_mode: "manual",
|
|
||||||
}),
|
|
||||||
[countryCode, cycleKey, regionId, role, settlementType],
|
|
||||||
);
|
|
||||||
const query = usePaginatedQuery({
|
|
||||||
errorMessage: "获取 Host/Agency 待结算列表失败",
|
|
||||||
fetcher: listHostSalarySettlementPending,
|
|
||||||
filters,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
queryKey: ["host-salary-pending", filters, page],
|
|
||||||
});
|
|
||||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
|
||||||
const items = data.items || [];
|
|
||||||
const total = data.total || 0;
|
|
||||||
const allVisibleSelected = items.length > 0 && items.every((item) => selected.has(hostPendingKey(item)));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelected(new Set());
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]);
|
|
||||||
const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]);
|
|
||||||
const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]);
|
|
||||||
|
|
||||||
const changeFilter = (setter) => (value) => {
|
|
||||||
setter(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const changeRole = (value) => {
|
|
||||||
onRoleChange(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const resetFilters = () => {
|
|
||||||
onRoleChange("bd");
|
|
||||||
setSettlementType("half_month");
|
|
||||||
setCycleKey(currentCycleKey());
|
|
||||||
setCountryCode("");
|
|
||||||
setRegionId("");
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const toggleOne = (item) => {
|
|
||||||
setSelected((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
const key = hostPendingKey(item);
|
|
||||||
if (next.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toggleVisible = () => {
|
|
||||||
setSelected((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
items.forEach((item) => {
|
|
||||||
const key = hostPendingKey(item);
|
|
||||||
if (allVisibleSelected) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const settleRows = async (rows) => {
|
|
||||||
const userIds = rows
|
|
||||||
.map((item) => String(item.userId || "").trim())
|
|
||||||
.filter((id) => /^[1-9]\d*$/.test(id));
|
|
||||||
if (!userIds.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSettling(true);
|
|
||||||
try {
|
|
||||||
const result = await settleHostSalary({
|
|
||||||
country_code: countryCode || undefined,
|
|
||||||
cycle_key: cycleKey,
|
|
||||||
role,
|
|
||||||
region_id: Number(regionId || 0) || undefined,
|
|
||||||
settlement_type: settlementType,
|
|
||||||
trigger_mode: "manual",
|
|
||||||
user_ids: userIds,
|
|
||||||
});
|
|
||||||
showToast({
|
|
||||||
message: `结算完成:成功 ${result.successCount},跳过 ${result.skippedCount},失败 ${result.failureCount}`,
|
|
||||||
severity: result.failureCount > 0 ? "warning" : "success",
|
|
||||||
});
|
|
||||||
setSelected(new Set());
|
|
||||||
await query.reload();
|
|
||||||
} catch (error) {
|
|
||||||
showToast({ message: error?.message || "Host/Agency 工资结算失败", severity: "error" });
|
|
||||||
} finally {
|
|
||||||
setSettling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const settleSelected = () => {
|
|
||||||
settleRows(items.filter((item) => selected.has(hostPendingKey(item))));
|
|
||||||
};
|
|
||||||
const tableColumns = [
|
|
||||||
{
|
|
||||||
key: "select",
|
|
||||||
label: (
|
|
||||||
<Checkbox
|
|
||||||
checked={allVisibleSelected}
|
|
||||||
disabled={!items.length}
|
|
||||||
indeterminate={selected.size > 0 && !allVisibleSelected}
|
|
||||||
size="small"
|
|
||||||
onChange={toggleVisible}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: "64px",
|
|
||||||
resizable: false,
|
|
||||||
render: (item) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(hostPendingKey(item))}
|
|
||||||
size="small"
|
|
||||||
onChange={() => toggleOne(item)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...hostPendingColumns,
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
label: "操作",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (item) =>
|
|
||||||
<Button
|
|
||||||
disabled={!abilities.canSettleSalary || settling}
|
|
||||||
size="small"
|
|
||||||
startIcon={<PlayArrowOutlined fontSize="small" />}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => settleRows([item])}
|
|
||||||
>
|
|
||||||
结算
|
|
||||||
</Button>
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const hasFilters =
|
|
||||||
role !== "bd" || settlementType !== "half_month" || cycleKey !== currentCycleKey() || countryCode || regionId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AdminListToolbar
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
disabled={!abilities.canSettleSalary || settling || selected.size === 0}
|
|
||||||
startIcon={<PlayArrowOutlined fontSize="small" />}
|
|
||||||
variant="primary"
|
|
||||||
onClick={settleSelected}
|
|
||||||
>
|
|
||||||
批量结算
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
filters={
|
|
||||||
<div className={styles.toolbarFilters}>
|
|
||||||
<AdminFilterSelect label="角色" options={pendingRoleOptions} value={role} onChange={changeRole} />
|
|
||||||
<AdminFilterSelect
|
|
||||||
label="结算类型"
|
|
||||||
options={settlementTypeOptions.filter(([value]) => value !== "")}
|
|
||||||
value={settlementType}
|
|
||||||
onChange={changeFilter(setSettlementType)}
|
|
||||||
/>
|
|
||||||
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
|
||||||
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
|
|
||||||
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
|
|
||||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
|
||||||
<AdminListBody>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
items={items}
|
|
||||||
minWidth="1540px"
|
|
||||||
pagination={paginationProps(data, page, total, setPage)}
|
|
||||||
rowKey={hostPendingKey}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
|
||||||
</DataState>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。
|
// HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。
|
||||||
function HostRecordsPanel() {
|
function HostRecordsPanel() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -438,28 +212,28 @@ function HostRecordsPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。
|
// TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。
|
||||||
function TeamPendingPanel({ onRoleChange, role }) {
|
function TeamPendingPanel() {
|
||||||
const abilities = useHostAgencyPolicyAbilities();
|
const abilities = useHostAgencyPolicyAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [policyType, setPolicyType] = useState("bd");
|
||||||
|
const [triggerMode, setTriggerMode] = useState("automatic");
|
||||||
const [cycleKey, setCycleKey] = useState(defaultCycleKey);
|
const [cycleKey, setCycleKey] = useState(defaultCycleKey);
|
||||||
const [countryCode, setCountryCode] = useState("");
|
const [countryCode, setCountryCode] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [selected, setSelected] = useState(() => new Set());
|
const [selected, setSelected] = useState(() => new Set());
|
||||||
const [settling, setSettling] = useState(false);
|
const [settling, setSettling] = useState(false);
|
||||||
|
|
||||||
// 待结算只处理人工政策;自动政策没有人工待结算入口,只在记录里查实际落库结果。
|
// BD/Admin 默认查看 automatic 上月周期;手动政策也可切换 triggerMode 后人工补发。
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
country_code: countryCode,
|
country_code: countryCode,
|
||||||
cycle_key: cycleKey,
|
cycle_key: cycleKey,
|
||||||
policy_type: role,
|
policy_type: policyType,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
trigger_mode: "manual",
|
trigger_mode: triggerMode,
|
||||||
}),
|
}),
|
||||||
[countryCode, cycleKey, regionId, role],
|
[countryCode, cycleKey, policyType, regionId, triggerMode],
|
||||||
);
|
);
|
||||||
const query = usePaginatedQuery({
|
const query = usePaginatedQuery({
|
||||||
errorMessage: "获取 BD/Admin 待结算列表失败",
|
errorMessage: "获取 BD/Admin 待结算列表失败",
|
||||||
@ -479,21 +253,14 @@ function TeamPendingPanel({ onRoleChange, role }) {
|
|||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]);
|
|
||||||
const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]);
|
|
||||||
const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]);
|
|
||||||
|
|
||||||
const changeFilter = (setter) => (value) => {
|
const changeFilter = (setter) => (value) => {
|
||||||
// 国家/区域/周期变化后必须重算候选,不保留旧页码。
|
// 国家/区域/周期变化后必须重算候选,不保留旧页码。
|
||||||
setter(value);
|
setter(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
const changeRole = (value) => {
|
|
||||||
onRoleChange(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
onRoleChange("bd");
|
setPolicyType("bd");
|
||||||
|
setTriggerMode("automatic");
|
||||||
setCycleKey(defaultCycleKey());
|
setCycleKey(defaultCycleKey());
|
||||||
setCountryCode("");
|
setCountryCode("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
@ -531,8 +298,8 @@ function TeamPendingPanel({ onRoleChange, role }) {
|
|||||||
// 只从当前页 items 中提取已勾选用户,服务端仍会再次按 user_ids 白名单过滤。
|
// 只从当前页 items 中提取已勾选用户,服务端仍会再次按 user_ids 白名单过滤。
|
||||||
const userIds = items
|
const userIds = items
|
||||||
.filter((item) => selected.has(teamPendingKey(item)))
|
.filter((item) => selected.has(teamPendingKey(item)))
|
||||||
.map((item) => String(item.userId || "").trim())
|
.map((item) => Number(item.userId))
|
||||||
.filter((id) => /^[1-9]\d*$/.test(id));
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
if (!userIds.length) {
|
if (!userIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -542,9 +309,9 @@ function TeamPendingPanel({ onRoleChange, role }) {
|
|||||||
const result = await settleTeamSalary({
|
const result = await settleTeamSalary({
|
||||||
country_code: countryCode || undefined,
|
country_code: countryCode || undefined,
|
||||||
cycle_key: cycleKey,
|
cycle_key: cycleKey,
|
||||||
policy_type: role,
|
policy_type: policyType,
|
||||||
region_id: Number(regionId || 0) || undefined,
|
region_id: Number(regionId || 0) || undefined,
|
||||||
trigger_mode: "manual",
|
trigger_mode: triggerMode,
|
||||||
user_ids: userIds,
|
user_ids: userIds,
|
||||||
});
|
});
|
||||||
showToast({
|
showToast({
|
||||||
@ -580,8 +347,9 @@ function TeamPendingPanel({ onRoleChange, role }) {
|
|||||||
},
|
},
|
||||||
...teamPendingColumns,
|
...teamPendingColumns,
|
||||||
];
|
];
|
||||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 手动政策 + 上月周期”的标准入口。
|
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||||
const hasFilters = role !== "bd" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
const hasFilters =
|
||||||
|
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -600,13 +368,34 @@ function TeamPendingPanel({ onRoleChange, role }) {
|
|||||||
<div className={styles.toolbarFilters}>
|
<div className={styles.toolbarFilters}>
|
||||||
<AdminFilterSelect
|
<AdminFilterSelect
|
||||||
label="角色"
|
label="角色"
|
||||||
options={pendingRoleOptions}
|
options={policyTypeOptions}
|
||||||
value={role}
|
value={policyType}
|
||||||
onChange={changeRole}
|
onChange={changeFilter(setPolicyType)}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="政策触发"
|
||||||
|
options={triggerOptions}
|
||||||
|
value={triggerMode}
|
||||||
|
onChange={changeFilter(setTriggerMode)}
|
||||||
|
/>
|
||||||
|
<InlineFilter
|
||||||
|
label="周期"
|
||||||
|
placeholder="YYYY-MM"
|
||||||
|
value={cycleKey}
|
||||||
|
onChange={changeFilter(setCycleKey)}
|
||||||
|
/>
|
||||||
|
<InlineFilter
|
||||||
|
label="国家"
|
||||||
|
placeholder="国家代码"
|
||||||
|
value={countryCode}
|
||||||
|
onChange={changeFilter(setCountryCode)}
|
||||||
|
/>
|
||||||
|
<InlineFilter
|
||||||
|
label="区域"
|
||||||
|
placeholder="区域 ID"
|
||||||
|
value={regionId}
|
||||||
|
onChange={changeFilter(setRegionId)}
|
||||||
/>
|
/>
|
||||||
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
|
||||||
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
|
|
||||||
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
|
|
||||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -823,89 +612,6 @@ const hostRecordColumns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// hostPendingColumns 展示 Host/Agency 当前账单差额;结算按钮单独放在操作列,便于自动政策行只读展示。
|
|
||||||
const hostPendingColumns = [
|
|
||||||
{
|
|
||||||
key: "host",
|
|
||||||
label: "主播",
|
|
||||||
width: "minmax(240px, 1.1fr)",
|
|
||||||
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "agency",
|
|
||||||
label: "代理",
|
|
||||||
width: "minmax(220px, 1fr)",
|
|
||||||
render: (item) => <UserCell fallbackId={item.agencyOwnerUserId} user={item.agencyOwner} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "scope",
|
|
||||||
label: "周期/政策",
|
|
||||||
width: "minmax(230px, 1fr)",
|
|
||||||
render: (item) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>
|
|
||||||
{item.cycleKey || "-"} · {settlementTypeLabel(item.settlementType)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
|
|
||||||
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "level",
|
|
||||||
label: "等级/钻石",
|
|
||||||
width: "minmax(160px, 0.75fr)",
|
|
||||||
render: (item) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
|
||||||
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
|
|
||||||
<span className={styles.meta}>门槛 {formatNumber(item.requiredDiamonds)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "hostReward",
|
|
||||||
label: "主播待发",
|
|
||||||
width: "minmax(170px, 0.8fr)",
|
|
||||||
render: (item) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
|
|
||||||
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "agencyReward",
|
|
||||||
label: "代理/剩余",
|
|
||||||
width: "minmax(170px, 0.8fr)",
|
|
||||||
render: (item) => <AgencyRewardCell item={item} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "total",
|
|
||||||
label: "待发总额",
|
|
||||||
width: "minmax(150px, 0.7fr)",
|
|
||||||
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.totalUsdDelta)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "progress",
|
|
||||||
label: "已结进度",
|
|
||||||
width: "minmax(170px, 0.8fr)",
|
|
||||||
render: (item) => (
|
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>等级 {item.settledLevelNo || 0}</span>
|
|
||||||
<span className={styles.meta}>主播 ${item.settledHostSalaryUsd || "0"}</span>
|
|
||||||
<span className={styles.meta}>代理 ${item.settledAgencySalaryUsd || "0"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "状态",
|
|
||||||
width: "minmax(120px, 0.55fr)",
|
|
||||||
render: (item) => <StatusCell item={item} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。
|
// teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。
|
||||||
const teamPendingColumns = [
|
const teamPendingColumns = [
|
||||||
{
|
{
|
||||||
@ -1045,10 +751,7 @@ function CycleCell({ item }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{item.cycleKey || "-"}</span>
|
<span>{item.cycleKey || "-"}</span>
|
||||||
<span className={styles.meta}>
|
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
|
||||||
{settlementTypeLabel(item.settlementType)} · {triggerLabel(item.triggerMode)} ·{" "}
|
|
||||||
{roleLabel(item.settlementRole)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1121,10 +824,6 @@ function teamPendingKey(item) {
|
|||||||
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostPendingKey(item) {
|
|
||||||
return `${item.role}:${item.settlementType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。
|
// userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。
|
||||||
function userIdText(user, fallbackId) {
|
function userIdText(user, fallbackId) {
|
||||||
const id = String(user?.userId || fallbackId || "").trim();
|
const id = String(user?.userId || fallbackId || "").trim();
|
||||||
@ -1143,28 +842,6 @@ function defaultCycleKey() {
|
|||||||
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
|
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentCycleKey 计算当前 UTC 自然月,和 Host/Agency 周期账户的 yyyy-MM 保持一致。
|
|
||||||
function currentCycleKey() {
|
|
||||||
const now = new Date();
|
|
||||||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCycleOptions(selectedCycleKey, monthCount) {
|
|
||||||
const now = new Date();
|
|
||||||
const anchor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
||||||
const options = [];
|
|
||||||
for (let index = 0; index < monthCount; index += 1) {
|
|
||||||
const item = new Date(anchor);
|
|
||||||
item.setUTCMonth(anchor.getUTCMonth() - index);
|
|
||||||
const value = `${item.getUTCFullYear()}-${String(item.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
||||||
options.push([value, value]);
|
|
||||||
}
|
|
||||||
if (selectedCycleKey && !options.some(([value]) => value === selectedCycleKey)) {
|
|
||||||
return [[selectedCycleKey, selectedCycleKey], ...options];
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
// settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。
|
// settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。
|
||||||
function settlementTypeLabel(value) {
|
function settlementTypeLabel(value) {
|
||||||
return (
|
return (
|
||||||
@ -1183,7 +860,6 @@ function statusLabel(value) {
|
|||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
pending: "待结算",
|
|
||||||
skipped: "跳过",
|
skipped: "跳过",
|
||||||
succeeded: "成功",
|
succeeded: "成功",
|
||||||
}[value] ||
|
}[value] ||
|
||||||
@ -1197,7 +873,6 @@ function statusTone(value) {
|
|||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
failed: "danger",
|
failed: "danger",
|
||||||
pending: "warning",
|
|
||||||
skipped: "warning",
|
skipped: "warning",
|
||||||
succeeded: "running",
|
succeeded: "running",
|
||||||
}[value] || "stopped"
|
}[value] || "stopped"
|
||||||
@ -1206,17 +881,7 @@ function statusTone(value) {
|
|||||||
|
|
||||||
// roleLabel 把 policy_type 展示为工资角色名称。
|
// roleLabel 把 policy_type 展示为工资角色名称。
|
||||||
function roleLabel(value) {
|
function roleLabel(value) {
|
||||||
return (
|
return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-";
|
||||||
{
|
|
||||||
admin: "Admin",
|
|
||||||
agency: "Agency",
|
|
||||||
all: "Host/Agency",
|
|
||||||
bd: "BD",
|
|
||||||
host: "Host",
|
|
||||||
}[value] ||
|
|
||||||
value ||
|
|
||||||
"-"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// triggerLabel 把触发方式展示为自动/手动。
|
// triggerLabel 把触发方式展示为自动/手动。
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
createCoinSeller,
|
createCoinSeller,
|
||||||
createRegion,
|
createRegion,
|
||||||
creditCoinSellerStock,
|
creditCoinSellerStock,
|
||||||
debitCoinSellerStock,
|
|
||||||
deleteAgency,
|
deleteAgency,
|
||||||
disableRegion,
|
disableRegion,
|
||||||
listAgencies,
|
listAgencies,
|
||||||
@ -34,23 +33,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await listBDs({
|
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
|
||||||
page: 1,
|
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
||||||
page_size: 10,
|
|
||||||
parent_leader_user_id: 21,
|
|
||||||
region_id: 7,
|
|
||||||
sort_by: "salary_wallet",
|
|
||||||
sort_direction: "asc",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
await listAgencies({
|
|
||||||
keyword: "agency",
|
|
||||||
page: 2,
|
|
||||||
page_size: 10,
|
|
||||||
parent_bd_user_id: 31,
|
|
||||||
sort_by: "salary_wallet",
|
|
||||||
sort_direction: "desc",
|
|
||||||
});
|
|
||||||
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
||||||
await listCoinSellers({
|
await listCoinSellers({
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -63,25 +47,20 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
await createBDLeader({
|
await createBDLeader({
|
||||||
commandId: "bd-leader-test",
|
commandId: "bd-leader-test",
|
||||||
positionAlias: "Senior Admin",
|
positionAlias: "Senior Admin",
|
||||||
targetUserId: "1002",
|
targetUserId: 1002,
|
||||||
});
|
});
|
||||||
await updateBDLeaderPositionAlias("1002", {
|
await updateBDLeaderPositionAlias(1002, {
|
||||||
commandId: "bd-leader-position-alias-test",
|
commandId: "bd-leader-position-alias-test",
|
||||||
positionAlias: "Regional Admin",
|
positionAlias: "Regional Admin",
|
||||||
});
|
});
|
||||||
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: "1001" });
|
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
|
||||||
await setCoinSellerStatus("1001", { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
|
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
|
||||||
await creditCoinSellerStock("1001", {
|
await creditCoinSellerStock(1001, {
|
||||||
coinAmount: 8000000,
|
coinAmount: 8000000,
|
||||||
commandId: "coin-seller-stock-test",
|
commandId: "coin-seller-stock-test",
|
||||||
rechargeAmount: "100.000000",
|
rechargeAmount: "100.000000",
|
||||||
type: "usdt_purchase",
|
type: "usdt_purchase",
|
||||||
});
|
});
|
||||||
await debitCoinSellerStock("1001", {
|
|
||||||
coinAmount: 1000,
|
|
||||||
commandId: "coin-seller-stock-debit-test",
|
|
||||||
reason: "deduct bad stock",
|
|
||||||
});
|
|
||||||
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
||||||
|
|
||||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
@ -93,20 +72,15 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
|
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
|
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
|
||||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
|
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
|
||||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9];
|
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[9];
|
||||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10];
|
|
||||||
|
|
||||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||||
expect(String(bdUrl)).toContain("region_id=7");
|
expect(String(bdUrl)).toContain("region_id=7");
|
||||||
expect(String(bdUrl)).toContain("sort_by=salary_wallet");
|
|
||||||
expect(String(bdUrl)).toContain("sort_direction=asc");
|
|
||||||
expect(String(bdUrl)).toContain("status=active");
|
expect(String(bdUrl)).toContain("status=active");
|
||||||
expect(bdInit?.method).toBe("GET");
|
expect(bdInit?.method).toBe("GET");
|
||||||
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
|
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
|
||||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||||
expect(String(agencyUrl)).toContain("sort_by=salary_wallet");
|
|
||||||
expect(String(agencyUrl)).toContain("sort_direction=desc");
|
|
||||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||||
expect(String(hostUrl)).toContain("agency_id=41");
|
expect(String(hostUrl)).toContain("agency_id=41");
|
||||||
expect(String(hostUrl)).toContain("sort_by=diamond");
|
expect(String(hostUrl)).toContain("sort_by=diamond");
|
||||||
@ -130,8 +104,6 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(coinSellerStatusInit?.method).toBe("PATCH");
|
expect(coinSellerStatusInit?.method).toBe("PATCH");
|
||||||
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
|
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
|
||||||
expect(coinSellerStockInit?.method).toBe("POST");
|
expect(coinSellerStockInit?.method).toBe("POST");
|
||||||
expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
|
|
||||||
expect(coinSellerStockDebitInit?.method).toBe("POST");
|
|
||||||
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
|
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
|
||||||
expect(agencyDeleteInit?.method).toBe("POST");
|
expect(agencyDeleteInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import type {
|
|||||||
CoinSellerSalaryRatesPayload,
|
CoinSellerSalaryRatesPayload,
|
||||||
CoinSellerStockCreditDto,
|
CoinSellerStockCreditDto,
|
||||||
CoinSellerStockCreditPayload,
|
CoinSellerStockCreditPayload,
|
||||||
CoinSellerStockDebitDto,
|
|
||||||
CoinSellerStockDebitPayload,
|
|
||||||
CoinSellerStatusPayload,
|
CoinSellerStatusPayload,
|
||||||
CountryDto,
|
CountryDto,
|
||||||
CountryPayload,
|
CountryPayload,
|
||||||
@ -33,9 +31,6 @@ import type {
|
|||||||
RegionDto,
|
RegionDto,
|
||||||
RegionPayload,
|
RegionPayload,
|
||||||
RegionUpdatePayload,
|
RegionUpdatePayload,
|
||||||
SalaryWalletAdjustPayload,
|
|
||||||
SalaryWalletAdjustResultDto,
|
|
||||||
SalaryWalletHistoryDto,
|
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
|
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
|
||||||
@ -184,20 +179,6 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<SalaryWalletHistoryDto>> {
|
|
||||||
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
|
|
||||||
method: "GET",
|
|
||||||
query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise<SalaryWalletAdjustResultDto> {
|
|
||||||
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>("/v1/admin/host-org/salary-wallets/adjust", {
|
|
||||||
body: payload,
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {
|
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {
|
||||||
const endpoint = API_ENDPOINTS.createBDLeader;
|
const endpoint = API_ENDPOINTS.createBDLeader;
|
||||||
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
|
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
|
||||||
@ -285,19 +266,6 @@ export function creditCoinSellerStock(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function debitCoinSellerStock(
|
|
||||||
userId: EntityId,
|
|
||||||
payload: CoinSellerStockDebitPayload,
|
|
||||||
): Promise<CoinSellerStockDebitDto> {
|
|
||||||
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
|
|
||||||
`/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`,
|
|
||||||
{
|
|
||||||
body: payload,
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
|
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
|
||||||
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
|
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
|
||||||
return apiRequest<CoinSellerSalaryRatesDto>(
|
return apiRequest<CoinSellerSalaryRatesDto>(
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
|
||||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
|
||||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
|
||||||
|
|
||||||
export function HostOrgSortHeader({ field, label, onToggle, sortBy, sortDirection }) {
|
|
||||||
const active = sortBy === field;
|
|
||||||
const SortIcon =
|
|
||||||
active && sortDirection === "asc"
|
|
||||||
? ArrowUpwardOutlined
|
|
||||||
: active && sortDirection === "desc"
|
|
||||||
? ArrowDownwardOutlined
|
|
||||||
: SwapVertOutlined;
|
|
||||||
const directionLabel = sortDirection === "asc" ? "升序" : "降序";
|
|
||||||
const nextDirectionLabel = active && sortDirection === "desc" ? "升序" : "降序";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={`按${label}${nextDirectionLabel}排序`}
|
|
||||||
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggle(field)}
|
|
||||||
>
|
|
||||||
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
|
|
||||||
<span className="admin-cell__head-label">
|
|
||||||
{label}
|
|
||||||
{active ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
|
||||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { adjustSalaryWallet, listSalaryWalletHistory } from "@/features/host-org/api";
|
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
|
||||||
import {
|
|
||||||
AdminFormAmountField,
|
|
||||||
AdminFormDialog,
|
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormReadOnlyField,
|
|
||||||
AdminFormSection,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
|
||||||
|
|
||||||
const historyPageSize = 20;
|
|
||||||
|
|
||||||
const emptyHistory = { items: [], page: 1, pageSize: historyPageSize, total: 0 };
|
|
||||||
|
|
||||||
const emptyAdjustForm = () => ({
|
|
||||||
amount: "",
|
|
||||||
reason: "",
|
|
||||||
type: "increase",
|
|
||||||
});
|
|
||||||
|
|
||||||
const historyColumns = [
|
|
||||||
{
|
|
||||||
key: "createdAtMs",
|
|
||||||
label: "时间",
|
|
||||||
render: (item) => formatMillis(item.createdAtMs),
|
|
||||||
width: "minmax(170px, 0.9fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "amountMinor",
|
|
||||||
label: "金额",
|
|
||||||
render: (item) => <SalaryHistoryAmount item={item} />,
|
|
||||||
width: "minmax(120px, 0.65fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "balance",
|
|
||||||
label: "余额",
|
|
||||||
render: (item) => formatSalaryWalletAmount(item.availableAfter),
|
|
||||||
width: "minmax(120px, 0.65fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bizType",
|
|
||||||
label: "类型",
|
|
||||||
render: (item) => salaryWalletBizLabel(item.bizType),
|
|
||||||
width: "minmax(150px, 0.8fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "operator",
|
|
||||||
label: "操作人",
|
|
||||||
render: (item) => salaryWalletOperatorName(item),
|
|
||||||
width: "minmax(150px, 0.8fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reason",
|
|
||||||
label: "原因",
|
|
||||||
render: (item) => item.reason || "-",
|
|
||||||
width: "minmax(220px, 1.15fr)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SalaryWalletCell({ role, userId, userLabel, wallet }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const balance = Number(wallet?.availableAmount || 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className={styles.salaryWalletButton}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<span className={styles.salaryWalletAmount}>{formatSalaryWalletAmount(balance)}</span>
|
|
||||||
</button>
|
|
||||||
<SalaryWalletHistoryDrawer
|
|
||||||
open={open}
|
|
||||||
role={role}
|
|
||||||
userId={userId}
|
|
||||||
userLabel={userLabel}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SalaryWalletAdjustButton({ disabled = false, onAdjusted, role, userId, userLabel }) {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [form, setForm] = useState(emptyAdjustForm);
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (submitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpen(false);
|
|
||||||
setForm(emptyAdjustForm());
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
let amountMinor;
|
|
||||||
try {
|
|
||||||
amountMinor = parseSalaryUSDMinor(form.amount);
|
|
||||||
if (!String(form.reason || "").trim()) {
|
|
||||||
throw new Error("请填写原因");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "工资操作参数不正确", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await adjustSalaryWallet({
|
|
||||||
amountMinor,
|
|
||||||
commandId: makeSalaryWalletCommandId(role, userId),
|
|
||||||
reason: String(form.reason || "").trim(),
|
|
||||||
role,
|
|
||||||
targetUserId: userId,
|
|
||||||
type: form.type,
|
|
||||||
});
|
|
||||||
showToast("工资钱包已更新", "success");
|
|
||||||
setOpen(false);
|
|
||||||
setForm(emptyAdjustForm());
|
|
||||||
await onAdjusted?.(result);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message || "工资钱包操作失败", "error");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={disabled || !role || !userId}
|
|
||||||
label="工资操作"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<PaidOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
<AdminFormDialog
|
|
||||||
disabled={disabled}
|
|
||||||
loading={submitting}
|
|
||||||
open={open}
|
|
||||||
size="large"
|
|
||||||
submitDisabled={disabled || submitting || !role || !userId}
|
|
||||||
submitLabel="确认"
|
|
||||||
title="工资操作"
|
|
||||||
onClose={close}
|
|
||||||
onSubmit={submit}
|
|
||||||
>
|
|
||||||
<AdminFormSection title="目标身份">
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
||||||
<AdminFormReadOnlyField label="身份" value={salaryWalletRoleLabel(role)} />
|
|
||||||
<AdminFormReadOnlyField label="用户" value={userLabel || userId || "-"} />
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="操作信息">
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
||||||
<TextField
|
|
||||||
disabled={disabled || submitting}
|
|
||||||
label="类型"
|
|
||||||
required
|
|
||||||
select
|
|
||||||
value={form.type}
|
|
||||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
|
|
||||||
>
|
|
||||||
<MenuItem value="increase">工资增加</MenuItem>
|
|
||||||
<MenuItem value="decrease">工资减少</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
<AdminFormAmountField
|
|
||||||
disabled={disabled || submitting}
|
|
||||||
label="金额"
|
|
||||||
required
|
|
||||||
unit="USD"
|
|
||||||
value={form.amount}
|
|
||||||
onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled || submitting}
|
|
||||||
label="原因"
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
required
|
|
||||||
value={form.reason}
|
|
||||||
onChange={(event) => setForm((current) => ({ ...current, reason: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</AdminFormSection>
|
|
||||||
</AdminFormDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SalaryWalletHistoryDrawer({ onClose, open, role, userId, userLabel }) {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [data, setData] = useState(emptyHistory);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
|
||||||
if (!open || !role || !userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const nextData = await listSalaryWalletHistory({
|
|
||||||
page,
|
|
||||||
page_size: historyPageSize,
|
|
||||||
role,
|
|
||||||
user_id: userId,
|
|
||||||
});
|
|
||||||
setData(nextData || emptyHistory);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || "加载工资钱包历史失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [open, page, role, userId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadHistory();
|
|
||||||
}, [loadHistory, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
}, [open, role, userId]);
|
|
||||||
|
|
||||||
const total = data.total || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SideDrawer
|
|
||||||
className={styles.salaryWalletDrawer}
|
|
||||||
contentClassName={styles.salaryWalletDrawerBody}
|
|
||||||
open={open}
|
|
||||||
title={`工资钱包历史 - ${userLabel || userId || ""}`}
|
|
||||||
width="wide"
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<div className={styles.salaryWalletSummary}>
|
|
||||||
<AccountBalanceWalletOutlined fontSize="small" />
|
|
||||||
<span>{salaryWalletRoleLabel(role)}</span>
|
|
||||||
</div>
|
|
||||||
<DataState error={error} loading={loading} onRetry={loadHistory}>
|
|
||||||
<DataTable
|
|
||||||
columns={historyColumns}
|
|
||||||
emptyLabel="暂无工资钱包历史"
|
|
||||||
items={data.items || []}
|
|
||||||
minWidth="930px"
|
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page,
|
|
||||||
pageSize: data.pageSize || historyPageSize,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={(item) => item.entryId || item.transactionId}
|
|
||||||
/>
|
|
||||||
</DataState>
|
|
||||||
</SideDrawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SalaryHistoryAmount({ item }) {
|
|
||||||
const delta = Number(item.availableDelta ?? item.amountMinor ?? 0);
|
|
||||||
const expense = delta < 0;
|
|
||||||
return (
|
|
||||||
<span className={expense ? styles.salaryWalletExpense : styles.salaryWalletIncome}>
|
|
||||||
{expense ? "-" : "+"}
|
|
||||||
{formatSalaryWalletAmount(Math.abs(delta))}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSalaryWalletAmount(value) {
|
|
||||||
const minor = Number(value || 0);
|
|
||||||
return `$${(minor / 100).toLocaleString("en-US", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSalaryUSDMinor(value) {
|
|
||||||
const raw = String(value || "").trim();
|
|
||||||
if (!/^\d+(\.\d{1,2})?$/.test(raw)) {
|
|
||||||
throw new Error("请输入正确的 USD 金额,最多两位小数");
|
|
||||||
}
|
|
||||||
const [integerPart, decimalPart = ""] = raw.split(".");
|
|
||||||
const amountMinor = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0"));
|
|
||||||
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
|
|
||||||
throw new Error("请输入大于 0 的工资金额");
|
|
||||||
}
|
|
||||||
return amountMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function salaryWalletRoleLabel(role) {
|
|
||||||
switch (role) {
|
|
||||||
case "host":
|
|
||||||
return "Host";
|
|
||||||
case "agency":
|
|
||||||
return "Agency";
|
|
||||||
case "bd":
|
|
||||||
return "BD";
|
|
||||||
case "bd_leader":
|
|
||||||
return "BD Leader";
|
|
||||||
default:
|
|
||||||
return role || "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function salaryWalletBizLabel(value) {
|
|
||||||
switch (value) {
|
|
||||||
case "manual_credit":
|
|
||||||
return "手工调整";
|
|
||||||
case "salary_settlement":
|
|
||||||
case "host_salary_settlement":
|
|
||||||
case "team_salary_settlement":
|
|
||||||
return "工资结算";
|
|
||||||
case "transfer":
|
|
||||||
return "转账";
|
|
||||||
case "withdraw":
|
|
||||||
return "提现";
|
|
||||||
default:
|
|
||||||
return value || "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function salaryWalletOperatorName(item) {
|
|
||||||
const operator = item.operator || {};
|
|
||||||
return operator.name || operator.username || item.operatorUserId || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSalaryWalletCommandId(role, userId) {
|
|
||||||
return `salary-wallet-${role || "role"}-${userId || "user"}-${Date.now()}`;
|
|
||||||
}
|
|
||||||
@ -28,8 +28,6 @@ export function useHostAgenciesPage() {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
const [parentBdUserId, setParentBdUserId] = useState("");
|
||||||
const [sortBy, setSortBy] = useState("");
|
|
||||||
const [sortDirection, setSortDirection] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
|
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
|
||||||
const [rowPatches, setRowPatches] = useState({});
|
const [rowPatches, setRowPatches] = useState({});
|
||||||
@ -41,11 +39,9 @@ export function useHostAgenciesPage() {
|
|||||||
keyword: query,
|
keyword: query,
|
||||||
parent_bd_user_id: parentBdUserId,
|
parent_bd_user_id: parentBdUserId,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
sort_by: sortBy,
|
|
||||||
sort_direction: sortDirection,
|
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[parentBdUserId, query, regionId, sortBy, sortDirection, status],
|
[parentBdUserId, query, regionId, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -101,19 +97,11 @@ export function useHostAgenciesPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeSort = (field) => {
|
|
||||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
|
||||||
setSortBy(field);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
setParentBdUserId("");
|
setParentBdUserId("");
|
||||||
setSortBy("");
|
|
||||||
setSortDirection("");
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -224,11 +212,8 @@ export function useHostAgenciesPage() {
|
|||||||
removeAgencyFromList,
|
removeAgencyFromList,
|
||||||
setAgencyForm,
|
setAgencyForm,
|
||||||
setPage,
|
setPage,
|
||||||
sortBy,
|
|
||||||
sortDirection,
|
|
||||||
status,
|
status,
|
||||||
submitAgency,
|
submitAgency,
|
||||||
changeSort,
|
|
||||||
toggleAgencyJoinEnabledFromList,
|
toggleAgencyJoinEnabledFromList,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,8 +38,6 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
|
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
|
||||||
const [sortBy, setSortBy] = useState("");
|
|
||||||
const [sortDirection, setSortDirection] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
||||||
const [bdForm, setBDForm] = useState(emptyBDForm);
|
const [bdForm, setBDForm] = useState(emptyBDForm);
|
||||||
@ -56,11 +54,9 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
keyword: query,
|
keyword: query,
|
||||||
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
sort_by: sortBy,
|
|
||||||
sort_direction: sortDirection,
|
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[parentLeaderUserId, profileType, query, regionId, sortBy, sortDirection, status],
|
[parentLeaderUserId, profileType, query, regionId, status],
|
||||||
);
|
);
|
||||||
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
||||||
const {
|
const {
|
||||||
@ -162,19 +158,11 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeSort = (field) => {
|
|
||||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
|
||||||
setSortBy(field);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
setParentLeaderUserId("");
|
setParentLeaderUserId("");
|
||||||
setSortBy("");
|
|
||||||
setSortDirection("");
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -373,13 +361,10 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
|||||||
setBDForm,
|
setBDForm,
|
||||||
setBDLeaderForm,
|
setBDLeaderForm,
|
||||||
setPage,
|
setPage,
|
||||||
sortBy,
|
|
||||||
sortDirection,
|
|
||||||
status,
|
status,
|
||||||
submitBD,
|
submitBD,
|
||||||
submitBDLeader,
|
submitBDLeader,
|
||||||
submitLeaderBD,
|
submitLeaderBD,
|
||||||
changeSort,
|
|
||||||
toggleBD,
|
toggleBD,
|
||||||
toggleBDLeader,
|
toggleBDLeader,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|||||||
import {
|
import {
|
||||||
createCoinSeller,
|
createCoinSeller,
|
||||||
creditCoinSellerStock,
|
creditCoinSellerStock,
|
||||||
debitCoinSellerStock,
|
|
||||||
getCoinSellerSalaryRates,
|
getCoinSellerSalaryRates,
|
||||||
listCoinSellers,
|
listCoinSellers,
|
||||||
replaceCoinSellerSalaryRates,
|
replaceCoinSellerSalaryRates,
|
||||||
@ -16,7 +15,6 @@ import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
|
|||||||
import {
|
import {
|
||||||
coinSellerStatusSchema,
|
coinSellerStatusSchema,
|
||||||
coinSellerStockCreditSchema,
|
coinSellerStockCreditSchema,
|
||||||
coinSellerStockDebitSchema,
|
|
||||||
createCoinSellerSchema,
|
createCoinSellerSchema,
|
||||||
} from "@/features/host-org/schema";
|
} from "@/features/host-org/schema";
|
||||||
|
|
||||||
@ -37,11 +35,6 @@ const emptyStockForm = () => ({
|
|||||||
rechargeAmount: "",
|
rechargeAmount: "",
|
||||||
type: "usdt_purchase",
|
type: "usdt_purchase",
|
||||||
});
|
});
|
||||||
const emptyStockDebitForm = () => ({
|
|
||||||
coinAmount: "",
|
|
||||||
commandId: makeCommandId("coin-seller-stock-debit"),
|
|
||||||
reason: "",
|
|
||||||
});
|
|
||||||
const emptyRateTier = (index = 0) => ({
|
const emptyRateTier = (index = 0) => ({
|
||||||
coinPerUsd: "",
|
coinPerUsd: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -64,7 +57,6 @@ export function useHostCoinSellersPage() {
|
|||||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
const [stockForm, setStockForm] = useState(emptyStockForm);
|
const [stockForm, setStockForm] = useState(emptyStockForm);
|
||||||
const [stockDebitForm, setStockDebitForm] = useState(emptyStockDebitForm);
|
|
||||||
const [rateRegionId, setRateRegionId] = useState("");
|
const [rateRegionId, setRateRegionId] = useState("");
|
||||||
const [rateTiers, setRateTiers] = useState([]);
|
const [rateTiers, setRateTiers] = useState([]);
|
||||||
const [loadingRates, setLoadingRates] = useState(false);
|
const [loadingRates, setLoadingRates] = useState(false);
|
||||||
@ -166,15 +158,6 @@ export function useHostCoinSellersPage() {
|
|||||||
setActiveAction("stock");
|
setActiveAction("stock");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openStockDebit = (seller) => {
|
|
||||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedSeller(seller);
|
|
||||||
setStockDebitForm(emptyStockDebitForm());
|
|
||||||
setActiveAction("stock-debit");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openSellerLedger = (seller) => {
|
const openSellerLedger = (seller) => {
|
||||||
if (!seller?.userId || !abilities.canLedger) {
|
if (!seller?.userId || !abilities.canLedger) {
|
||||||
return;
|
return;
|
||||||
@ -325,20 +308,6 @@ export function useHostCoinSellersPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitStockDebit = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!selectedSeller?.userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runAction(`coin-seller-stock-debit-${selectedSeller.userId}`, "币商金币已扣除", async () => {
|
|
||||||
const payload = parseForm(coinSellerStockDebitSchema, stockDebitForm);
|
|
||||||
await debitCoinSellerStock(selectedSeller.userId, payload);
|
|
||||||
setStockDebitForm(emptyStockDebitForm());
|
|
||||||
closeAction();
|
|
||||||
await reload();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRateSettings = async (event) => {
|
const submitRateSettings = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!rateRegionId) {
|
if (!rateRegionId) {
|
||||||
@ -414,7 +383,6 @@ export function useHostCoinSellersPage() {
|
|||||||
openEditSeller,
|
openEditSeller,
|
||||||
openSellerLedger,
|
openSellerLedger,
|
||||||
openStockCredit,
|
openStockCredit,
|
||||||
openStockDebit,
|
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
regionId,
|
regionId,
|
||||||
@ -432,9 +400,7 @@ export function useHostCoinSellersPage() {
|
|||||||
setEditForm,
|
setEditForm,
|
||||||
setPage,
|
setPage,
|
||||||
setStockForm,
|
setStockForm,
|
||||||
setStockDebitForm,
|
|
||||||
status,
|
status,
|
||||||
stockDebitForm,
|
|
||||||
stockForm,
|
stockForm,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
@ -442,7 +408,6 @@ export function useHostCoinSellersPage() {
|
|||||||
submitCreateSeller,
|
submitCreateSeller,
|
||||||
submitEditSeller,
|
submitEditSeller,
|
||||||
submitStockCredit,
|
submitStockCredit,
|
||||||
submitStockDebit,
|
|
||||||
toggleSeller,
|
toggleSeller,
|
||||||
updateRateTier,
|
updateRateTier,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,19 +14,18 @@ export function useHostHostsPage() {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [agencyId, setAgencyId] = useState("");
|
const [agencyId, setAgencyId] = useState("");
|
||||||
const [sortBy, setSortBy] = useState("");
|
const [diamondSortDirection, setDiamondSortDirection] = useState("");
|
||||||
const [sortDirection, setSortDirection] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
agency_id: agencyId,
|
agency_id: agencyId,
|
||||||
keyword: query,
|
keyword: query,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
sort_by: sortBy,
|
sort_by: diamondSortDirection ? "diamond" : "",
|
||||||
sort_direction: sortDirection,
|
sort_direction: diamondSortDirection,
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[agencyId, query, regionId, sortBy, sortDirection, status],
|
[agencyId, diamondSortDirection, query, regionId, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -62,9 +61,8 @@ export function useHostHostsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeSort = (field) => {
|
const toggleDiamondSort = () => {
|
||||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc"));
|
||||||
setSortBy(field);
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -73,8 +71,7 @@ export function useHostHostsPage() {
|
|||||||
setStatus("");
|
setStatus("");
|
||||||
setRegionId("");
|
setRegionId("");
|
||||||
setAgencyId("");
|
setAgencyId("");
|
||||||
setSortBy("");
|
setDiamondSortDirection("");
|
||||||
setSortDirection("");
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,7 +83,7 @@ export function useHostHostsPage() {
|
|||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
data,
|
data,
|
||||||
diamondSortDirection: sortBy === "diamond" ? sortDirection : "",
|
diamondSortDirection,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
@ -97,10 +94,7 @@ export function useHostHostsPage() {
|
|||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
setPage,
|
setPage,
|
||||||
sortBy,
|
|
||||||
sortDirection,
|
|
||||||
status,
|
status,
|
||||||
changeSort,
|
toggleDiamondSort,
|
||||||
toggleDiamondSort: () => changeSort("diamond"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -276,77 +276,6 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.salaryWalletButton {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 34px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletButton:hover .salaryWalletAmount {
|
|
||||||
color: var(--primary-strong);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletAmount {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 780;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletDrawer.salaryWalletDrawer {
|
|
||||||
width: min(980px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletDrawerBody.salaryWalletDrawerBody {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletDrawerBody :global(.data-state) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletSummary {
|
|
||||||
display: inline-flex;
|
|
||||||
width: fit-content;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-control);
|
|
||||||
background: var(--bg-input);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletIncome {
|
|
||||||
color: var(--success);
|
|
||||||
font-weight: 780;
|
|
||||||
}
|
|
||||||
|
|
||||||
.salaryWalletExpense {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 780;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contactPlaceholder {
|
.contactPlaceholder {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
|||||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
|
||||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
||||||
@ -29,21 +27,6 @@ const agencyColumns = [
|
|||||||
render: (item) => <AgencyOwner item={item} />,
|
render: (item) => <AgencyOwner item={item} />,
|
||||||
width: "minmax(220px, 1.2fr)",
|
width: "minmax(220px, 1.2fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "salaryWallet",
|
|
||||||
label: "工资钱包",
|
|
||||||
sortable: true,
|
|
||||||
sortField: "salary_wallet",
|
|
||||||
render: (item) => (
|
|
||||||
<SalaryWalletCell
|
|
||||||
role="agency"
|
|
||||||
userId={item.ownerUserId}
|
|
||||||
userLabel={agencyOwnerLabel(item)}
|
|
||||||
wallet={item.salaryWallet}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: "minmax(140px, 0.85fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "parentBdUserId",
|
key: "parentBdUserId",
|
||||||
label: "上级 BD",
|
label: "上级 BD",
|
||||||
@ -78,7 +61,7 @@ const agencyColumns = [
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
|
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
|
||||||
width: "minmax(118px, 0.6fr)",
|
width: "minmax(80px, 0.45fr)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -124,27 +107,15 @@ export function HostAgenciesPage() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (column.sortable) {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
header: (
|
|
||||||
<HostOrgSortHeader
|
|
||||||
field={column.sortField}
|
|
||||||
label={column.label}
|
|
||||||
sortBy={page.sortBy}
|
|
||||||
sortDirection={page.sortDirection}
|
|
||||||
onToggle={page.changeSort}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return column;
|
return column;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar actions={toolbarActions} />
|
<HostOrgToolbar
|
||||||
|
actions={toolbarActions}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
@ -152,7 +123,7 @@ export function HostAgenciesPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1340px"
|
minWidth="1180px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -206,7 +177,9 @@ export function HostAgenciesPage() {
|
|||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
label="入会状态"
|
label="入会状态"
|
||||||
uncheckedLabel="禁止"
|
uncheckedLabel="禁止"
|
||||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })}
|
onChange={(event) =>
|
||||||
|
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={createDisabled}
|
disabled={createDisabled}
|
||||||
@ -274,14 +247,6 @@ function AgencyJoinSwitch({ item, page }) {
|
|||||||
function AgencyActions({ item, page }) {
|
function AgencyActions({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
{page?.abilities.canSalaryAdjust ? (
|
|
||||||
<SalaryWalletAdjustButton
|
|
||||||
role="agency"
|
|
||||||
userId={item.ownerUserId}
|
|
||||||
userLabel={agencyOwnerLabel(item)}
|
|
||||||
onAdjusted={page.reload}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{page?.abilities.canDelete ? (
|
{page?.abilities.canDelete ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
|
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
|
||||||
@ -304,7 +269,3 @@ const dangerActionSx = {
|
|||||||
backgroundColor: "var(--danger-surface)",
|
backgroundColor: "var(--danger-surface)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function agencyOwnerLabel(item) {
|
|
||||||
return item.ownerDisplayUserId || item.ownerUsername || item.ownerUserId;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,14 +10,10 @@ import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLay
|
|||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
|
||||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
|
||||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
|
||||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
const bdLeaderBaseColumns = [
|
const bdLeaderBaseColumns = [
|
||||||
@ -33,21 +29,6 @@ const bdLeaderBaseColumns = [
|
|||||||
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
||||||
width: "minmax(130px, 0.75fr)",
|
width: "minmax(130px, 0.75fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "salaryWallet",
|
|
||||||
label: "工资钱包",
|
|
||||||
sortable: true,
|
|
||||||
sortField: "salary_wallet",
|
|
||||||
render: (item) => (
|
|
||||||
<SalaryWalletCell
|
|
||||||
role="bd_leader"
|
|
||||||
userId={item.userId}
|
|
||||||
userLabel={leaderUserLabel(item)}
|
|
||||||
wallet={item.salaryWallet}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: "minmax(140px, 0.85fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "positionAlias",
|
key: "positionAlias",
|
||||||
label: "职位别名",
|
label: "职位别名",
|
||||||
@ -85,51 +66,17 @@ export function HostBdLeadersPage() {
|
|||||||
const createDisabled = !page.abilities.canCreate;
|
const createDisabled = !page.abilities.canCreate;
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const regionFilter = createRegionColumnFilter({
|
|
||||||
loading: page.loadingRegions,
|
|
||||||
options: page.regionOptions,
|
|
||||||
value: page.regionId,
|
|
||||||
onChange: page.changeRegionId,
|
|
||||||
});
|
|
||||||
const bdLeaderColumns = [
|
const bdLeaderColumns = [
|
||||||
...bdLeaderBaseColumns,
|
...bdLeaderBaseColumns,
|
||||||
page.abilities.canCreate || page.abilities.canUpdate || page.abilities.canSalaryAdjust
|
page.abilities.canCreate || page.abilities.canUpdate
|
||||||
? {
|
? {
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => <BDLeaderActions item={item} page={page} />,
|
render: (item) => <BDLeaderActions item={item} page={page} />,
|
||||||
width: "minmax(154px, 0.8fr)",
|
width: "minmax(118px, 0.7fr)",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
]
|
].filter(Boolean);
|
||||||
.filter(Boolean)
|
|
||||||
.map((column) => {
|
|
||||||
if (column.key === "user") {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
filter: createTextColumnFilter({
|
|
||||||
placeholder: "搜索用户 ID、展示 ID、用户名",
|
|
||||||
value: page.query,
|
|
||||||
onChange: page.changeQuery,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (column.sortable) {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
header: (
|
|
||||||
<HostOrgSortHeader
|
|
||||||
field={column.sortField}
|
|
||||||
label={column.label}
|
|
||||||
sortBy={page.sortBy}
|
|
||||||
sortDirection={page.sortDirection}
|
|
||||||
onToggle={page.changeSort}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
const toolbarActions = page.abilities.canCreate
|
const toolbarActions = page.abilities.canCreate
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@ -152,7 +99,7 @@ export function HostBdLeadersPage() {
|
|||||||
columns={bdLeaderColumns}
|
columns={bdLeaderColumns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1300px"
|
minWidth="1120px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -163,7 +110,6 @@ export function HostBdLeadersPage() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
regionFilter={regionFilter}
|
|
||||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -397,14 +343,6 @@ function SubBDCount({ item, page }) {
|
|||||||
function BDLeaderActions({ item, page }) {
|
function BDLeaderActions({ item, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
{page.abilities.canSalaryAdjust ? (
|
|
||||||
<SalaryWalletAdjustButton
|
|
||||||
role="bd_leader"
|
|
||||||
userId={item.userId}
|
|
||||||
userLabel={leaderUserLabel(item)}
|
|
||||||
onAdjusted={page.reload}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{page.abilities.canCreate ? (
|
{page.abilities.canCreate ? (
|
||||||
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
|
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
|
||||||
<PersonAddAlt1Outlined fontSize="small" />
|
<PersonAddAlt1Outlined fontSize="small" />
|
||||||
@ -464,7 +402,3 @@ const dangerActionSx = {
|
|||||||
color: "var(--danger)",
|
color: "var(--danger)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function leaderUserLabel(item) {
|
|
||||||
return item.displayUserId || item.username || item.userId;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx";
|
|
||||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
|
||||||
|
|
||||||
vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({
|
|
||||||
useHostBdsPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bd leader table exposes user and region column filters", () => {
|
|
||||||
const changeQuery = vi.fn();
|
|
||||||
const changeRegionId = vi.fn();
|
|
||||||
vi.mocked(useHostBdsPage).mockReturnValue(pageFixture({ changeQuery, changeRegionId }));
|
|
||||||
|
|
||||||
render(<HostBdLeadersPage />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "用户" }));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("搜索用户 ID、展示 ID、用户名"), {
|
|
||||||
target: { value: "164453" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "区域" }));
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "南亚区" }));
|
|
||||||
|
|
||||||
expect(changeQuery).toHaveBeenCalledWith("164453");
|
|
||||||
expect(changeRegionId).toHaveBeenCalledWith("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
function pageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: {
|
|
||||||
canCreate: false,
|
|
||||||
canUpdate: false,
|
|
||||||
canView: true,
|
|
||||||
},
|
|
||||||
activeAction: "",
|
|
||||||
bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" },
|
|
||||||
bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" },
|
|
||||||
bdUserLookup: { error: "", loading: false, user: null },
|
|
||||||
changeParentLeaderUserId: vi.fn(),
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeRegionId: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
closeAction: vi.fn(),
|
|
||||||
data: { items: [leaderFixture()], page: 1, pageSize: 50, total: 1 },
|
|
||||||
error: null,
|
|
||||||
leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 },
|
|
||||||
leaderBDsError: "",
|
|
||||||
leaderBDsLoading: false,
|
|
||||||
loadLeaderBDs: vi.fn(),
|
|
||||||
loading: false,
|
|
||||||
loadingAction: "",
|
|
||||||
loadingRegions: false,
|
|
||||||
openAddLeaderBD: vi.fn(),
|
|
||||||
openBDLeaderForm: vi.fn(),
|
|
||||||
openLeaderBDs: vi.fn(),
|
|
||||||
page: 1,
|
|
||||||
parentLeaderUserId: "",
|
|
||||||
query: "",
|
|
||||||
regionId: "",
|
|
||||||
regionOptions: [{ label: "南亚区", value: "1" }],
|
|
||||||
reload: vi.fn(),
|
|
||||||
removeBDLeader: vi.fn(),
|
|
||||||
resetFilters: vi.fn(),
|
|
||||||
saveBDLeaderPositionAlias: vi.fn(),
|
|
||||||
selectedLeader: null,
|
|
||||||
setBDForm: vi.fn(),
|
|
||||||
setBDLeaderForm: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
status: "",
|
|
||||||
submitBDLeader: vi.fn(),
|
|
||||||
submitLeaderBD: vi.fn(),
|
|
||||||
toggleBDLeader: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function leaderFixture() {
|
|
||||||
return {
|
|
||||||
avatar: "",
|
|
||||||
createdAtMs: 1760000000000,
|
|
||||||
createdByDisplayUserId: "164000",
|
|
||||||
createdByUserId: "1000",
|
|
||||||
createdByUsername: "hyappadmin",
|
|
||||||
displayUserId: "164453",
|
|
||||||
positionAlias: "admin",
|
|
||||||
regionId: "1",
|
|
||||||
regionName: "南亚区",
|
|
||||||
status: "active",
|
|
||||||
subBdCount: 0,
|
|
||||||
userId: "3001",
|
|
||||||
username: "ABER KING",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -6,12 +6,9 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
|||||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
|
||||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||||
import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
@ -23,16 +20,6 @@ const bdColumns = [
|
|||||||
width: "minmax(220px, 1.25fr)",
|
width: "minmax(220px, 1.25fr)",
|
||||||
},
|
},
|
||||||
{ key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
|
{ key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
|
||||||
{
|
|
||||||
key: "salaryWallet",
|
|
||||||
label: "工资钱包",
|
|
||||||
sortable: true,
|
|
||||||
sortField: "salary_wallet",
|
|
||||||
render: (item) => (
|
|
||||||
<SalaryWalletCell role="bd" userId={item.userId} userLabel={bdUserLabel(item)} wallet={item.salaryWallet} />
|
|
||||||
),
|
|
||||||
width: "minmax(140px, 0.85fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "parentLeaderUserId",
|
key: "parentLeaderUserId",
|
||||||
label: "上级 Leader",
|
label: "上级 Leader",
|
||||||
@ -81,71 +68,47 @@ export function HostBdsPage() {
|
|||||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||||
: null,
|
: null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const columns = [
|
const columns = bdColumns.map((column) => {
|
||||||
...bdColumns,
|
if (column.key === "user") {
|
||||||
page.abilities.canSalaryAdjust
|
return {
|
||||||
? {
|
...column,
|
||||||
key: "actions",
|
filter: createTextColumnFilter({
|
||||||
label: "操作",
|
placeholder: "搜索用户 ID、展示 ID、用户名",
|
||||||
render: (item, _index, context) => <BDActions item={item} page={context?.page} />,
|
value: page.query,
|
||||||
width: "minmax(80px, 0.45fr)",
|
onChange: page.changeQuery,
|
||||||
}
|
}),
|
||||||
: null,
|
};
|
||||||
]
|
}
|
||||||
.filter(Boolean)
|
if (column.key === "parentLeaderUserId") {
|
||||||
.map((column) => {
|
return {
|
||||||
if (column.key === "user") {
|
...column,
|
||||||
return {
|
filter: createTextColumnFilter({
|
||||||
...column,
|
placeholder: "搜索 Leader 用户 ID",
|
||||||
filter: createTextColumnFilter({
|
value: page.parentLeaderUserId,
|
||||||
placeholder: "搜索用户 ID、展示 ID、用户名",
|
onChange: page.changeParentLeaderUserId,
|
||||||
value: page.query,
|
}),
|
||||||
onChange: page.changeQuery,
|
};
|
||||||
}),
|
}
|
||||||
};
|
if (column.key === "status") {
|
||||||
}
|
return {
|
||||||
if (column.key === "parentLeaderUserId") {
|
...column,
|
||||||
return {
|
filter: createOptionsColumnFilter({
|
||||||
...column,
|
options: bdStatusFilters,
|
||||||
filter: createTextColumnFilter({
|
placeholder: "搜索状态",
|
||||||
placeholder: "搜索 Leader 用户 ID",
|
value: page.status,
|
||||||
value: page.parentLeaderUserId,
|
onChange: page.changeStatus,
|
||||||
onChange: page.changeParentLeaderUserId,
|
}),
|
||||||
}),
|
};
|
||||||
};
|
}
|
||||||
}
|
return column;
|
||||||
if (column.key === "status") {
|
});
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
filter: createOptionsColumnFilter({
|
|
||||||
options: bdStatusFilters,
|
|
||||||
placeholder: "搜索状态",
|
|
||||||
value: page.status,
|
|
||||||
onChange: page.changeStatus,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (column.sortable) {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
header: (
|
|
||||||
<HostOrgSortHeader
|
|
||||||
field={column.sortField}
|
|
||||||
label={column.label}
|
|
||||||
sortBy={page.sortBy}
|
|
||||||
sortDirection={page.sortDirection}
|
|
||||||
onToggle={page.changeSort}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<HostOrgToolbar actions={toolbarActions} />
|
<HostOrgToolbar
|
||||||
|
actions={toolbarActions}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
@ -153,7 +116,7 @@ export function HostBdsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1440px"
|
minWidth="1260px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -256,20 +219,3 @@ function Creator({ item }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BDActions({ item, page }) {
|
|
||||||
return (
|
|
||||||
<AdminRowActions>
|
|
||||||
<SalaryWalletAdjustButton
|
|
||||||
role="bd"
|
|
||||||
userId={item.userId}
|
|
||||||
userLabel={bdUserLabel(item)}
|
|
||||||
onAdjusted={page?.reload}
|
|
||||||
/>
|
|
||||||
</AdminRowActions>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bdUserLabel(item) {
|
|
||||||
return item.displayUserId || item.username || item.userId;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
|||||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||||
@ -80,7 +79,6 @@ export function HostCoinSellersPage() {
|
|||||||
const page = useHostCoinSellersPage();
|
const page = useHostCoinSellersPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const stockDebitForm = page.stockDebitForm || { coinAmount: "", reason: "" };
|
|
||||||
const createDisabled = !page.abilities.canCreate;
|
const createDisabled = !page.abilities.canCreate;
|
||||||
const updateDisabled = !page.abilities.canUpdate;
|
const updateDisabled = !page.abilities.canUpdate;
|
||||||
const columns = [
|
const columns = [
|
||||||
@ -97,7 +95,7 @@ export function HostCoinSellersPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => <SellerActions item={item} page={page} />,
|
render: (item) => <SellerActions item={item} page={page} />,
|
||||||
width: "minmax(130px, 0.65fr)",
|
width: "minmax(90px, 0.5fr)",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
@ -305,52 +303,6 @@ export function HostCoinSellersPage() {
|
|||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<HostOrgActionModal
|
|
||||||
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
|
||||||
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
|
|
||||||
open={page.activeAction === "stock-debit"}
|
|
||||||
sectioned={false}
|
|
||||||
size="standard"
|
|
||||||
submitLabel="确认扣除"
|
|
||||||
onClose={page.closeAction}
|
|
||||||
onSubmit={page.submitStockDebit}
|
|
||||||
title="币商金币扣除"
|
|
||||||
>
|
|
||||||
<AdminFormSection title="基础信息">
|
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
||||||
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
|
|
||||||
<AdminFormReadOnlyField
|
|
||||||
label="当前库存余额"
|
|
||||||
value={formatNumber(page.selectedSeller?.merchantBalance)}
|
|
||||||
/>
|
|
||||||
</AdminFormFieldGrid>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="扣除信息">
|
|
||||||
<AdminFormAmountField
|
|
||||||
disabled={!page.abilities.canStockCredit}
|
|
||||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
|
||||||
label="扣除金币"
|
|
||||||
required
|
|
||||||
unit="金币"
|
|
||||||
value={stockDebitForm.coinAmount}
|
|
||||||
onChange={(event) =>
|
|
||||||
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</AdminFormSection>
|
|
||||||
<AdminFormSection title="备注信息">
|
|
||||||
<TextField
|
|
||||||
disabled={!page.abilities.canStockCredit}
|
|
||||||
label="原因"
|
|
||||||
multiline
|
|
||||||
required
|
|
||||||
minRows={2}
|
|
||||||
value={stockDebitForm.reason}
|
|
||||||
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
|
|
||||||
/>
|
|
||||||
</AdminFormSection>
|
|
||||||
</HostOrgActionModal>
|
|
||||||
|
|
||||||
<SideDrawer
|
<SideDrawer
|
||||||
className={styles.ledgerDrawer}
|
className={styles.ledgerDrawer}
|
||||||
contentClassName={styles.ledgerDrawerBody}
|
contentClassName={styles.ledgerDrawerBody}
|
||||||
@ -512,16 +464,6 @@ function SellerActions({ item, page }) {
|
|||||||
<MonetizationOnOutlined fontSize="small" />
|
<MonetizationOnOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
) : null}
|
) : null}
|
||||||
{page.abilities.canStockCredit ? (
|
|
||||||
<AdminActionIconButton
|
|
||||||
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
|
|
||||||
label="扣金币"
|
|
||||||
sx={coinDebitActionSx}
|
|
||||||
onClick={() => page.openStockDebit(item)}
|
|
||||||
>
|
|
||||||
<RemoveCircleOutlineOutlined fontSize="small" />
|
|
||||||
</AdminActionIconButton>
|
|
||||||
) : null}
|
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -613,17 +555,3 @@ const coinCreditActionSx = {
|
|||||||
color: "#f59e0b",
|
color: "#f59e0b",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const coinDebitActionSx = {
|
|
||||||
borderColor: "rgba(220, 38, 38, 0.34)",
|
|
||||||
backgroundColor: "rgba(254, 226, 226, 0.65)",
|
|
||||||
color: "#b91c1c",
|
|
||||||
"&:hover": {
|
|
||||||
borderColor: "rgba(220, 38, 38, 0.58)",
|
|
||||||
backgroundColor: "rgba(254, 202, 202, 0.75)",
|
|
||||||
color: "#991b1b",
|
|
||||||
},
|
|
||||||
"& .MuiSvgIcon-root": {
|
|
||||||
color: "#dc2626",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
|
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||||
|
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||||
|
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
|
||||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
||||||
import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import styles from "@/features/host-org/host-org.module.css";
|
import styles from "@/features/host-org/host-org.module.css";
|
||||||
|
|
||||||
@ -27,26 +27,9 @@ const hostColumns = [
|
|||||||
{
|
{
|
||||||
key: "diamond",
|
key: "diamond",
|
||||||
label: "钻石",
|
label: "钻石",
|
||||||
sortable: true,
|
|
||||||
sortField: "diamond",
|
|
||||||
render: (item) => formatNumber(item.diamond),
|
render: (item) => formatNumber(item.diamond),
|
||||||
width: "minmax(110px, 0.7fr)",
|
width: "minmax(110px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "salaryWallet",
|
|
||||||
label: "工资钱包",
|
|
||||||
sortable: true,
|
|
||||||
sortField: "salary_wallet",
|
|
||||||
render: (item) => (
|
|
||||||
<SalaryWalletCell
|
|
||||||
role="host"
|
|
||||||
userId={item.userId}
|
|
||||||
userLabel={hostUserLabel(item)}
|
|
||||||
wallet={item.salaryWallet}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: "minmax(140px, 0.85fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "regionId",
|
key: "regionId",
|
||||||
label: "区域",
|
label: "区域",
|
||||||
@ -83,80 +66,51 @@ export function HostHostsPage() {
|
|||||||
const page = useHostHostsPage();
|
const page = useHostHostsPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const columns = [
|
const columns = hostColumns.map((column) => {
|
||||||
...hostColumns,
|
if (column.key === "user") {
|
||||||
page.abilities.canSalaryAdjust
|
return {
|
||||||
? {
|
...column,
|
||||||
key: "actions",
|
filter: createTextColumnFilter({
|
||||||
label: "操作",
|
placeholder: "搜索用户 ID、展示 ID、用户名、Agency",
|
||||||
render: (item, _index, context) => <HostActions item={item} page={context?.page} />,
|
value: page.query,
|
||||||
width: "minmax(80px, 0.45fr)",
|
onChange: page.changeQuery,
|
||||||
}
|
}),
|
||||||
: null,
|
};
|
||||||
]
|
}
|
||||||
.filter(Boolean)
|
if (column.key === "currentAgencyId") {
|
||||||
.map((column) => {
|
return {
|
||||||
if (column.key === "user") {
|
...column,
|
||||||
return {
|
filter: createTextColumnFilter({
|
||||||
...column,
|
placeholder: "搜索 Agency ID",
|
||||||
filter: createTextColumnFilter({
|
value: page.agencyId,
|
||||||
placeholder: "搜索用户 ID、展示 ID、用户名、Agency",
|
onChange: page.changeAgencyId,
|
||||||
value: page.query,
|
}),
|
||||||
onChange: page.changeQuery,
|
};
|
||||||
}),
|
}
|
||||||
};
|
if (column.key === "status") {
|
||||||
}
|
return {
|
||||||
if (column.key === "currentAgencyId") {
|
...column,
|
||||||
return {
|
filter: createOptionsColumnFilter({
|
||||||
...column,
|
options: hostStatusFilters,
|
||||||
filter: createTextColumnFilter({
|
placeholder: "搜索状态",
|
||||||
placeholder: "搜索 Agency ID",
|
value: page.status,
|
||||||
value: page.agencyId,
|
onChange: page.changeStatus,
|
||||||
onChange: page.changeAgencyId,
|
}),
|
||||||
}),
|
};
|
||||||
};
|
}
|
||||||
}
|
if (column.key === "diamond") {
|
||||||
if (column.key === "status") {
|
return {
|
||||||
return {
|
...column,
|
||||||
...column,
|
header: (
|
||||||
filter: createOptionsColumnFilter({
|
<DiamondSortHeader
|
||||||
options: hostStatusFilters,
|
direction={page.diamondSortDirection}
|
||||||
placeholder: "搜索状态",
|
onToggle={page.toggleDiamondSort}
|
||||||
value: page.status,
|
/>
|
||||||
onChange: page.changeStatus,
|
),
|
||||||
}),
|
};
|
||||||
};
|
}
|
||||||
}
|
return column;
|
||||||
if (column.key === "diamond") {
|
});
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
header: (
|
|
||||||
<HostOrgSortHeader
|
|
||||||
field={column.sortField}
|
|
||||||
label={column.label}
|
|
||||||
sortBy={page.sortBy}
|
|
||||||
sortDirection={page.sortDirection}
|
|
||||||
onToggle={page.changeSort}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (column.sortable) {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
header: (
|
|
||||||
<HostOrgSortHeader
|
|
||||||
field={column.sortField}
|
|
||||||
label={column.label}
|
|
||||||
sortBy={page.sortBy}
|
|
||||||
sortDirection={page.sortDirection}
|
|
||||||
onToggle={page.changeSort}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.root}>
|
<section className={styles.root}>
|
||||||
@ -165,9 +119,9 @@ export function HostHostsPage() {
|
|||||||
<div className={styles.listBlock}>
|
<div className={styles.listBlock}>
|
||||||
<HostOrgTable
|
<HostOrgTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1420px"
|
minWidth="1240px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -191,6 +145,28 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiamondSortHeader({ direction, onToggle }) {
|
||||||
|
const SortIcon =
|
||||||
|
direction === "asc" ? ArrowUpwardOutlined : direction === "desc" ? ArrowDownwardOutlined : SwapVertOutlined;
|
||||||
|
const directionLabel = direction === "asc" ? "升序" : "降序";
|
||||||
|
const nextDirectionLabel = direction === "desc" ? "升序" : "降序";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={`按钻石${nextDirectionLabel}排序`}
|
||||||
|
className={["admin-cell__head-trigger", direction ? "admin-cell__head-trigger--active" : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
|
||||||
|
<span className="admin-cell__head-label">
|
||||||
|
钻石{direction ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HostUser({ item }) {
|
function HostUser({ item }) {
|
||||||
return (
|
return (
|
||||||
<HostOrgPerson
|
<HostOrgPerson
|
||||||
@ -230,20 +206,3 @@ function HostStatusSwitch({ item }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostActions({ item, page }) {
|
|
||||||
return (
|
|
||||||
<AdminRowActions>
|
|
||||||
<SalaryWalletAdjustButton
|
|
||||||
role="host"
|
|
||||||
userId={item.userId}
|
|
||||||
userLabel={hostUserLabel(item)}
|
|
||||||
onAdjusted={page?.reload}
|
|
||||||
/>
|
|
||||||
</AdminRowActions>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostUserLabel(item) {
|
|
||||||
return item.displayUserId || item.username || item.userId;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,231 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
|
||||||
import { HostAgenciesPage } from "./HostAgenciesPage.jsx";
|
|
||||||
import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx";
|
|
||||||
import { HostBdsPage } from "./HostBdsPage.jsx";
|
|
||||||
import { HostHostsPage } from "./HostHostsPage.jsx";
|
|
||||||
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
|
||||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
|
||||||
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
|
||||||
|
|
||||||
vi.mock("@/features/host-org/hooks/useHostAgenciesPage.js", () => ({
|
|
||||||
useHostAgenciesPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({
|
|
||||||
useHostBdsPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/host-org/hooks/useHostHostsPage.js", () => ({
|
|
||||||
useHostHostsPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agency list salary wallet header requests salary wallet sorting", () => {
|
|
||||||
const changeSort = vi.fn();
|
|
||||||
vi.mocked(useHostAgenciesPage).mockReturnValue(agencyPageFixture({ changeSort }));
|
|
||||||
|
|
||||||
render(<HostAgenciesPage />);
|
|
||||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
|
||||||
|
|
||||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bd leader list salary wallet header requests salary wallet sorting", () => {
|
|
||||||
const changeSort = vi.fn();
|
|
||||||
vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "leader" }));
|
|
||||||
|
|
||||||
render(<HostBdLeadersPage />);
|
|
||||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
|
||||||
|
|
||||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bd list salary wallet header requests salary wallet sorting", () => {
|
|
||||||
const changeSort = vi.fn();
|
|
||||||
vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "bd" }));
|
|
||||||
|
|
||||||
render(<HostBdsPage />);
|
|
||||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
|
||||||
|
|
||||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("host list salary wallet header requests salary wallet sorting", () => {
|
|
||||||
const changeSort = vi.fn();
|
|
||||||
vi.mocked(useHostHostsPage).mockReturnValue(hostPageFixture({ changeSort }));
|
|
||||||
|
|
||||||
render(<HostHostsPage />);
|
|
||||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
|
||||||
|
|
||||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
|
||||||
});
|
|
||||||
|
|
||||||
function agencyPageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: { canCreate: false, canDelete: false, canSalaryAdjust: false, canStatus: false },
|
|
||||||
activeAction: "",
|
|
||||||
agencyForm: { joinEnabled: true, name: "", ownerUserId: "", parentBdUserId: "", reason: "" },
|
|
||||||
changeParentBdUserId: vi.fn(),
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeRegionId: vi.fn(),
|
|
||||||
changeSort: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
closeAction: vi.fn(),
|
|
||||||
closeAgencyFromList: vi.fn(),
|
|
||||||
data: { items: [agencyFixture()], page: 1, pageSize: 50, total: 1 },
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
loadingAction: "",
|
|
||||||
loadingRegions: false,
|
|
||||||
openAgencyForm: vi.fn(),
|
|
||||||
page: 1,
|
|
||||||
parentBdUserId: "",
|
|
||||||
query: "",
|
|
||||||
regionId: "",
|
|
||||||
regionOptions: [],
|
|
||||||
reload: vi.fn(),
|
|
||||||
removeAgencyFromList: vi.fn(),
|
|
||||||
resetFilters: vi.fn(),
|
|
||||||
setAgencyForm: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
sortBy: "",
|
|
||||||
sortDirection: "",
|
|
||||||
status: "",
|
|
||||||
submitAgency: vi.fn(),
|
|
||||||
toggleAgencyJoinEnabledFromList: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bdPageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: { canCreate: false, canSalaryAdjust: false, canUpdate: false, canView: true },
|
|
||||||
activeAction: "",
|
|
||||||
bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" },
|
|
||||||
bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" },
|
|
||||||
bdUserLookup: { error: "", loading: false, user: null },
|
|
||||||
changeParentLeaderUserId: vi.fn(),
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeRegionId: vi.fn(),
|
|
||||||
changeSort: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
closeAction: vi.fn(),
|
|
||||||
data: { items: [bdFixture()], page: 1, pageSize: 50, total: 1 },
|
|
||||||
error: null,
|
|
||||||
leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 },
|
|
||||||
leaderBDsError: "",
|
|
||||||
leaderBDsLoading: false,
|
|
||||||
loadLeaderBDs: vi.fn(),
|
|
||||||
loading: false,
|
|
||||||
loadingAction: "",
|
|
||||||
loadingRegions: false,
|
|
||||||
openAddLeaderBD: vi.fn(),
|
|
||||||
openBDForm: vi.fn(),
|
|
||||||
openBDLeaderForm: vi.fn(),
|
|
||||||
openLeaderBDs: vi.fn(),
|
|
||||||
page: 1,
|
|
||||||
parentLeaderUserId: "",
|
|
||||||
profileType: "bd",
|
|
||||||
query: "",
|
|
||||||
regionId: "",
|
|
||||||
regionOptions: [],
|
|
||||||
reload: vi.fn(),
|
|
||||||
removeBDLeader: vi.fn(),
|
|
||||||
resetFilters: vi.fn(),
|
|
||||||
saveBDLeaderPositionAlias: vi.fn(),
|
|
||||||
selectedLeader: null,
|
|
||||||
setBDForm: vi.fn(),
|
|
||||||
setBDLeaderForm: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
sortBy: "",
|
|
||||||
sortDirection: "",
|
|
||||||
status: "",
|
|
||||||
submitBD: vi.fn(),
|
|
||||||
submitBDLeader: vi.fn(),
|
|
||||||
submitLeaderBD: vi.fn(),
|
|
||||||
toggleBD: vi.fn(),
|
|
||||||
toggleBDLeader: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostPageFixture(patch = {}) {
|
|
||||||
return {
|
|
||||||
abilities: { canSalaryAdjust: false, canView: true },
|
|
||||||
agencyId: "",
|
|
||||||
changeAgencyId: vi.fn(),
|
|
||||||
changeQuery: vi.fn(),
|
|
||||||
changeRegionId: vi.fn(),
|
|
||||||
changeSort: vi.fn(),
|
|
||||||
changeStatus: vi.fn(),
|
|
||||||
data: { items: [hostFixture()], page: 1, pageSize: 50, total: 1 },
|
|
||||||
diamondSortDirection: "",
|
|
||||||
error: null,
|
|
||||||
loading: false,
|
|
||||||
loadingRegions: false,
|
|
||||||
page: 1,
|
|
||||||
query: "",
|
|
||||||
regionId: "",
|
|
||||||
regionOptions: [],
|
|
||||||
reload: vi.fn(),
|
|
||||||
resetFilters: vi.fn(),
|
|
||||||
setPage: vi.fn(),
|
|
||||||
sortBy: "",
|
|
||||||
sortDirection: "",
|
|
||||||
status: "",
|
|
||||||
toggleDiamondSort: vi.fn(),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function agencyFixture() {
|
|
||||||
return {
|
|
||||||
agencyId: "9001",
|
|
||||||
joinEnabled: true,
|
|
||||||
name: "Agency One",
|
|
||||||
ownerDisplayUserId: "160001",
|
|
||||||
ownerUserId: "1001",
|
|
||||||
ownerUsername: "agency_owner",
|
|
||||||
parentBdUserId: "2001",
|
|
||||||
regionName: "中东区",
|
|
||||||
salaryWallet: { availableAmount: 1200 },
|
|
||||||
status: "active",
|
|
||||||
updatedAtMs: 1760000000000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bdFixture() {
|
|
||||||
return {
|
|
||||||
createdAtMs: 1760000000000,
|
|
||||||
displayUserId: "160002",
|
|
||||||
parentLeaderUserId: "3001",
|
|
||||||
regionName: "中东区",
|
|
||||||
role: "bd",
|
|
||||||
salaryWallet: { availableAmount: 1200 },
|
|
||||||
status: "active",
|
|
||||||
subBdCount: 0,
|
|
||||||
updatedAtMs: 1760000000000,
|
|
||||||
userId: "2001",
|
|
||||||
username: "bd_user",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostFixture() {
|
|
||||||
return {
|
|
||||||
currentAgencyId: "9001",
|
|
||||||
diamond: 100,
|
|
||||||
displayUserId: "160003",
|
|
||||||
firstBecameHostAtMs: 1760000000000,
|
|
||||||
regionName: "中东区",
|
|
||||||
salaryWallet: { availableAmount: 1200 },
|
|
||||||
source: "agency_invite",
|
|
||||||
status: "active",
|
|
||||||
updatedAtMs: 1760000000000,
|
|
||||||
userId: "3001",
|
|
||||||
username: "host_user",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -29,7 +29,6 @@ export function useAgencyAbilities() {
|
|||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.agencyCreate),
|
canCreate: can(PERMISSIONS.agencyCreate),
|
||||||
canDelete: can(PERMISSIONS.agencyDelete),
|
canDelete: can(PERMISSIONS.agencyDelete),
|
||||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
|
||||||
canStatus: can(PERMISSIONS.agencyStatus),
|
canStatus: can(PERMISSIONS.agencyStatus),
|
||||||
canView: can(PERMISSIONS.agencyView),
|
canView: can(PERMISSIONS.agencyView),
|
||||||
};
|
};
|
||||||
@ -39,7 +38,6 @@ export function useHostAbilities() {
|
|||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
|
||||||
canView: can(PERMISSIONS.hostView),
|
canView: can(PERMISSIONS.hostView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -49,7 +47,6 @@ export function useBDAbilities() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.bdCreate),
|
canCreate: can(PERMISSIONS.bdCreate),
|
||||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
|
||||||
canUpdate: can(PERMISSIONS.bdUpdate),
|
canUpdate: can(PERMISSIONS.bdUpdate),
|
||||||
canView: can(PERMISSIONS.bdView),
|
canView: can(PERMISSIONS.bdView),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,15 +11,7 @@ const commandContactSchema = z.object({
|
|||||||
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
|
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const userIdSchema = z.preprocess(
|
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
|
||||||
(value) => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return String(value).trim();
|
|
||||||
},
|
|
||||||
z.string().min(1, "请输入有效用户短 ID").regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
|
|
||||||
);
|
|
||||||
const entityIdSchema = z.preprocess(
|
const entityIdSchema = z.preprocess(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@ -32,14 +24,14 @@ const entityIdSchema = z.preprocess(
|
|||||||
const optionalUserIdSchema = z.preprocess(
|
const optionalUserIdSchema = z.preprocess(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return "0";
|
return 0;
|
||||||
}
|
}
|
||||||
if (typeof value === "string" && value.trim() === "") {
|
if (typeof value === "string" && value.trim() === "") {
|
||||||
return "0";
|
return 0;
|
||||||
}
|
}
|
||||||
return String(value).trim();
|
return value;
|
||||||
},
|
},
|
||||||
z.string().regex(/^(0|[1-9]\d*)$/, "请输入有效用户短 ID"),
|
z.coerce.number().int().min(0, "请输入有效用户短 ID"),
|
||||||
);
|
);
|
||||||
const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID");
|
const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID");
|
||||||
const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0);
|
const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0);
|
||||||
@ -130,12 +122,6 @@ export const coinSellerStockCreditSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const coinSellerStockDebitSchema = z.object({
|
|
||||||
coinAmount: z.coerce.number().int().positive("请输入扣除金币"),
|
|
||||||
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
|
||||||
reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createAgencySchema = commandBaseSchema.extend({
|
export const createAgencySchema = commandBaseSchema.extend({
|
||||||
joinEnabled: z.boolean().default(true),
|
joinEnabled: z.boolean().default(true),
|
||||||
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
|
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
|
||||||
@ -187,7 +173,6 @@ export type BDStatusForm = z.infer<typeof bdStatusSchema>;
|
|||||||
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||||
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
|
||||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export interface InviteActivityRewardConfigPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteActivityRewardClaimUserDto {
|
export interface InviteActivityRewardClaimUserDto {
|
||||||
userId?: string;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@ -55,7 +55,7 @@ export interface InviteActivityRewardClaimDto {
|
|||||||
claimId: string;
|
claimId: string;
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
cycleKey?: string;
|
cycleKey?: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
user?: InviteActivityRewardClaimUserDto;
|
user?: InviteActivityRewardClaimUserDto;
|
||||||
rewardType?: string;
|
rewardType?: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
@ -75,32 +75,9 @@ export interface InviteActivityRewardClaimDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteActivityRewardInviteeSummaryDto {
|
|
||||||
userId: string;
|
|
||||||
user?: InviteActivityRewardClaimUserDto;
|
|
||||||
rechargeCoinAmount: number;
|
|
||||||
invitedAtMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InviteActivityRewardSummaryDto {
|
|
||||||
key: string;
|
|
||||||
appCode?: string;
|
|
||||||
cycleKey?: string;
|
|
||||||
userId: string;
|
|
||||||
user?: InviteActivityRewardClaimUserDto;
|
|
||||||
inviteCount: number;
|
|
||||||
validInviteCount: number;
|
|
||||||
totalRechargeCoinAmount: number;
|
|
||||||
totalRewardCoinAmount: number;
|
|
||||||
updatedAtMs?: number;
|
|
||||||
invitees: InviteActivityRewardInviteeSummaryDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
||||||
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
||||||
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
||||||
type RawInviteeSummary = InviteActivityRewardInviteeSummaryDto & Record<string, unknown>;
|
|
||||||
type RawSummary = InviteActivityRewardSummaryDto & Record<string, unknown>;
|
|
||||||
|
|
||||||
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
||||||
@ -133,18 +110,6 @@ export function listInviteActivityRewardClaims(query: PageQuery = {}): Promise<A
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listInviteActivityRewardSummaries(
|
|
||||||
query: PageQuery = {},
|
|
||||||
): Promise<ApiPage<InviteActivityRewardSummaryDto>> {
|
|
||||||
return apiRequest<ApiPage<RawSummary>>("/v1/admin/activity/invite-reward/summaries", {
|
|
||||||
method: "GET",
|
|
||||||
query,
|
|
||||||
}).then((page) => ({
|
|
||||||
...page,
|
|
||||||
items: (page.items || []).map(normalizeSummary),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
||||||
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||||
return {
|
return {
|
||||||
@ -185,9 +150,9 @@ function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
|||||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
|
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
user: {
|
user: {
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
username: stringValue(rawUser.username),
|
username: stringValue(rawUser.username),
|
||||||
avatar: stringValue(rawUser.avatar),
|
avatar: stringValue(rawUser.avatar),
|
||||||
@ -211,45 +176,6 @@ function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSummary(item: RawSummary): InviteActivityRewardSummaryDto {
|
|
||||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
|
||||||
const rawInvitees = Array.isArray(item.invitees) ? (item.invitees as RawInviteeSummary[]) : [];
|
|
||||||
const cycleKey = stringValue(item.cycleKey ?? item.cycle_key);
|
|
||||||
const userId = stringValue(item.userId ?? item.user_id);
|
|
||||||
return {
|
|
||||||
key: `${cycleKey}:${userId}`,
|
|
||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
|
||||||
cycleKey,
|
|
||||||
userId,
|
|
||||||
user: normalizeUser(rawUser),
|
|
||||||
inviteCount: numberValue(item.inviteCount ?? item.invite_count),
|
|
||||||
validInviteCount: numberValue(item.validInviteCount ?? item.valid_invite_count),
|
|
||||||
totalRechargeCoinAmount: numberValue(item.totalRechargeCoinAmount ?? item.total_recharge_coin_amount),
|
|
||||||
totalRewardCoinAmount: numberValue(item.totalRewardCoinAmount ?? item.total_reward_coin_amount),
|
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
|
||||||
invitees: rawInvitees.map(normalizeInviteeSummary),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInviteeSummary(item: RawInviteeSummary): InviteActivityRewardInviteeSummaryDto {
|
|
||||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
|
||||||
user: normalizeUser(rawUser),
|
|
||||||
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
|
||||||
invitedAtMs: numberValue(item.invitedAtMs ?? item.invited_at_ms),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUser(rawUser: Record<string, unknown>): InviteActivityRewardClaimUserDto {
|
|
||||||
return {
|
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
|
||||||
username: stringValue(rawUser.username),
|
|
||||||
avatar: stringValue(rawUser.avatar),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown) {
|
function stringValue(value: unknown) {
|
||||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import styles from "@/features/invite-activity-reward/invite-activity-reward.mod
|
|||||||
|
|
||||||
const rewardTypeOptions = [
|
const rewardTypeOptions = [
|
||||||
["recharge", "累计充值"],
|
["recharge", "累计充值"],
|
||||||
["valid_invite", "Valid Users"],
|
["valid_invite", "有效人数"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export function InviteActivityRewardConfigDrawer({
|
export function InviteActivityRewardConfigDrawer({
|
||||||
|
|||||||
@ -25,8 +25,8 @@ export function InviteActivityRewardConfigSummary({ canUpdate, config, configLoa
|
|||||||
<SummaryItem label="累计充值档位">
|
<SummaryItem label="累计充值档位">
|
||||||
{thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")}
|
{thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")}
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
<SummaryItem label="Valid Users Tiers">
|
<SummaryItem label="有效人数档位">
|
||||||
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "people")}
|
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "人")}
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
<SummaryItem label="邀请人即时奖励">
|
<SummaryItem label="邀请人即时奖励">
|
||||||
{coinSummary(config?.perInviteInviterRewardCoinAmount)}
|
{coinSummary(config?.perInviteInviterRewardCoinAmount)}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getInviteActivityRewardConfig,
|
getInviteActivityRewardConfig,
|
||||||
listInviteActivityRewardSummaries,
|
listInviteActivityRewardClaims,
|
||||||
updateInviteActivityRewardConfig,
|
updateInviteActivityRewardConfig,
|
||||||
} from "@/features/invite-activity-reward/api";
|
} from "@/features/invite-activity-reward/api";
|
||||||
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
||||||
@ -9,7 +9,7 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptySummaries = { items: [], page: 1, pageSize, total: 0 };
|
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
perInviteInviterRewardCoinAmount: "",
|
perInviteInviterRewardCoinAmount: "",
|
||||||
@ -27,23 +27,25 @@ export function useInviteActivityRewardPage() {
|
|||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [cycleKey, setCycleKey] = useState("");
|
const [cycleKey, setCycleKey] = useState("");
|
||||||
|
const [rewardType, setRewardType] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({ keyword: query, cycle_key: cycleKey }),
|
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
|
||||||
[cycleKey, query],
|
[cycleKey, query, rewardType, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data: summaries = emptySummaries,
|
data: claims = emptyClaims,
|
||||||
error: summariesError,
|
error: claimsError,
|
||||||
loading: summariesLoading,
|
loading: claimsLoading,
|
||||||
reload: reloadSummaries,
|
reload: reloadClaims,
|
||||||
} = usePaginatedQuery({
|
} = usePaginatedQuery({
|
||||||
errorMessage: "加载邀请活动列表失败",
|
errorMessage: "加载邀请活动领取记录失败",
|
||||||
fetcher: listInviteActivityRewardSummaries,
|
fetcher: listInviteActivityRewardClaims,
|
||||||
filters,
|
filters,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
queryKey: ["invite-activity-reward-summaries", filters, page],
|
queryKey: ["invite-activity-reward-claims", filters, page],
|
||||||
});
|
});
|
||||||
|
|
||||||
const reloadConfig = useCallback(async () => {
|
const reloadConfig = useCallback(async () => {
|
||||||
@ -81,6 +83,15 @@ export function useInviteActivityRewardPage() {
|
|||||||
setCycleKey(value);
|
setCycleKey(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
const changeRewardType = (value) => {
|
||||||
|
setRewardType(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const submitConfig = async (event) => {
|
const submitConfig = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
@ -93,7 +104,7 @@ export function useInviteActivityRewardPage() {
|
|||||||
setForm(formFromConfig(saved));
|
setForm(formFromConfig(saved));
|
||||||
setConfigDrawerOpen(false);
|
setConfigDrawerOpen(false);
|
||||||
showToast("邀请活动配置已保存", "success");
|
showToast("邀请活动配置已保存", "success");
|
||||||
await reloadSummaries();
|
await reloadClaims();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存邀请活动配置失败", "error");
|
showToast(err.message || "保存邀请活动配置失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -105,6 +116,11 @@ export function useInviteActivityRewardPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
changeCycleKey,
|
changeCycleKey,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
|
changeRewardType,
|
||||||
|
changeStatus,
|
||||||
|
claims,
|
||||||
|
claimsError,
|
||||||
|
claimsLoading,
|
||||||
closeConfigDrawer,
|
closeConfigDrawer,
|
||||||
config,
|
config,
|
||||||
configDrawerOpen,
|
configDrawerOpen,
|
||||||
@ -115,13 +131,12 @@ export function useInviteActivityRewardPage() {
|
|||||||
openConfigDrawer,
|
openConfigDrawer,
|
||||||
page,
|
page,
|
||||||
query,
|
query,
|
||||||
|
reloadClaims,
|
||||||
reloadConfig,
|
reloadConfig,
|
||||||
|
rewardType,
|
||||||
setForm,
|
setForm,
|
||||||
setPage,
|
setPage,
|
||||||
reloadSummaries,
|
status,
|
||||||
summaries,
|
|
||||||
summariesError,
|
|
||||||
summariesLoading,
|
|
||||||
submitConfig,
|
submitConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -165,16 +180,17 @@ function payloadFromForm(form) {
|
|||||||
throw new Error("累计充值金币门槛必须大于 0");
|
throw new Error("累计充值金币门槛必须大于 0");
|
||||||
}
|
}
|
||||||
if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) {
|
if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) {
|
||||||
throw new Error("Valid users threshold must be greater than 0");
|
throw new Error("有效人数门槛必须大于 0");
|
||||||
}
|
}
|
||||||
if (rewardCoinAmount <= 0) {
|
if (rewardCoinAmount <= 0) {
|
||||||
throw new Error("奖励金币必须大于 0");
|
throw new Error("奖励金币必须大于 0");
|
||||||
}
|
}
|
||||||
const tierCode = normalizeTierCode(tier.tierCode, rewardType, index);
|
const tierCode = String(tier.tierCode || "").trim() || `invite_${rewardType}_${Date.now()}_${index + 1}`;
|
||||||
const tierName =
|
const tierName =
|
||||||
rewardType === "recharge"
|
String(tier.tierName || "").trim() ||
|
||||||
? `Cumulative Recharge ${formatNumber(thresholdCoinAmount)} coins`
|
(rewardType === "recharge"
|
||||||
: `Valid Users ${formatNumber(thresholdValidInviteCount)} people`;
|
? `累计充值 ${formatNumber(thresholdCoinAmount)} 金币`
|
||||||
|
: `有效邀请 ${formatNumber(thresholdValidInviteCount)} 人`);
|
||||||
return {
|
return {
|
||||||
tier_id: Number(tier.tierId || 0),
|
tier_id: Number(tier.tierId || 0),
|
||||||
reward_type: rewardType,
|
reward_type: rewardType,
|
||||||
@ -204,13 +220,5 @@ function payloadFromForm(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return new Intl.NumberFormat("en-US").format(Number(value || 0));
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTierCode(value, rewardType, index) {
|
|
||||||
const code = String(value || "").trim();
|
|
||||||
if (code && !/[\u3400-\u9fff]/.test(code)) {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
return `invite_${rewardType}_${Date.now()}_${index + 1}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,74 +83,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowExpanded {
|
|
||||||
background: var(--fill-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandUser {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandIcon {
|
|
||||||
display: inline-flex;
|
|
||||||
flex: 0 0 22px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailRow {
|
|
||||||
min-height: 0;
|
|
||||||
border-bottom: 1px solid var(--row-border);
|
|
||||||
background: var(--fill-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailCell {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 var(--space-3) var(--space-3) calc(var(--space-3) + 30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inviteeTable {
|
|
||||||
display: grid;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inviteeHeader,
|
|
||||||
.inviteeRow {
|
|
||||||
display: grid;
|
|
||||||
min-height: 48px;
|
|
||||||
align-items: center;
|
|
||||||
grid-template-columns: minmax(240px, 1fr) minmax(140px, 0.45fr) minmax(190px, 0.55fr);
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: 0 var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inviteeHeader {
|
|
||||||
min-height: 40px;
|
|
||||||
background: var(--bg-card-strong);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inviteeRow {
|
|
||||||
border-top: 1px solid var(--row-border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inviteeEmpty {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: var(--space-4);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawerSectionTitle {
|
.drawerSectionTitle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -184,9 +116,4 @@
|
|||||||
.inlineFields {
|
.inlineFields {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inviteeHeader,
|
|
||||||
.inviteeRow {
|
|
||||||
grid-template-columns: minmax(220px, 1fr) minmax(120px, 0.5fr) minmax(170px, 0.6fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
|
||||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
||||||
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
||||||
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
||||||
@ -9,64 +6,143 @@ import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
|||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const claimStatusOptions = [
|
||||||
|
["pending", "发放中"],
|
||||||
|
["granted", "已发放"],
|
||||||
|
["failed", "发放失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const rewardTypeOptions = [
|
||||||
|
["recharge", "累计充值"],
|
||||||
|
["valid_invite", "有效人数"],
|
||||||
|
["invite_inviter", "邀请人即时"],
|
||||||
|
["invite_invitee", "被邀请人即时"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户信息",
|
||||||
|
width: "minmax(260px, 1.1fr)",
|
||||||
|
render: (claim) => <ClaimUser claim={claim} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cycle",
|
||||||
|
label: "周期",
|
||||||
|
width: "minmax(140px, 0.6fr)",
|
||||||
|
render: (claim) => claim.cycleKey || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rewardType",
|
||||||
|
label: "奖励类型",
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
render: (claim) => rewardTypeLabel(claim.rewardType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tier",
|
||||||
|
label: "档位",
|
||||||
|
width: "minmax(210px, 0.9fr)",
|
||||||
|
render: (claim) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{claim.tierName || claim.tierCode || "-"}</span>
|
||||||
|
<span className={styles.meta}>{thresholdLabel(claim)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reached",
|
||||||
|
label: "达成值",
|
||||||
|
width: "minmax(150px, 0.65fr)",
|
||||||
|
render: (claim) => reachedLabel(claim),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reward",
|
||||||
|
label: "奖励金币",
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
render: (claim) => formatNumber(claim.rewardCoinAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
render: (claim) => claimStatusLabel(claim.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
label: "创建时间",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (claim) => formatMillis(claim.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "claimId",
|
||||||
|
label: "记录",
|
||||||
|
width: "minmax(280px, 1fr)",
|
||||||
|
render: (claim) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{claim.claimId}</span>
|
||||||
|
<span className={styles.meta}>{claim.walletTransactionId || claim.walletCommandId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (claim) => claim.failureReason || "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function InviteActivityRewardPage() {
|
export function InviteActivityRewardPage() {
|
||||||
const page = useInviteActivityRewardPage();
|
const page = useInviteActivityRewardPage();
|
||||||
const total = page.summaries.total || 0;
|
const total = page.claims.total || 0;
|
||||||
const [expandedKey, setExpandedKey] = useState("");
|
const columns = columnsBase.map((column) => {
|
||||||
const toggleSummary = (summary) => {
|
if (column.key === "user") {
|
||||||
setExpandedKey((current) => (current === summary.key ? "" : summary.key));
|
return {
|
||||||
};
|
...column,
|
||||||
const columns = [
|
filter: createTextColumnFilter({
|
||||||
{
|
placeholder: "搜索用户 ID、短号、名称",
|
||||||
key: "user",
|
value: page.query,
|
||||||
label: "用户信息",
|
onChange: page.changeQuery,
|
||||||
width: "minmax(280px, 1.1fr)",
|
}),
|
||||||
render: (summary) => <SummaryUser expanded={expandedKey === summary.key} summary={summary} />,
|
};
|
||||||
filter: createTextColumnFilter({
|
}
|
||||||
placeholder: "搜索用户 ID、短号、名称",
|
if (column.key === "cycle") {
|
||||||
value: page.query,
|
return {
|
||||||
onChange: page.changeQuery,
|
...column,
|
||||||
}),
|
filter: createTextColumnFilter({
|
||||||
},
|
placeholder: "周期,如 2026-06",
|
||||||
{
|
value: page.cycleKey,
|
||||||
key: "cycle",
|
onChange: page.changeCycleKey,
|
||||||
label: "周期",
|
}),
|
||||||
width: "minmax(140px, 0.55fr)",
|
};
|
||||||
render: (summary) => summary.cycleKey || "-",
|
}
|
||||||
filter: createTextColumnFilter({
|
if (column.key === "rewardType") {
|
||||||
placeholder: "周期,如 2026-06",
|
return {
|
||||||
value: page.cycleKey,
|
...column,
|
||||||
onChange: page.changeCycleKey,
|
filter: createOptionsColumnFilter({
|
||||||
}),
|
options: [["", "全部类型"], ...rewardTypeOptions],
|
||||||
},
|
placeholder: "奖励类型",
|
||||||
{
|
value: page.rewardType,
|
||||||
key: "inviteCount",
|
onChange: page.changeRewardType,
|
||||||
label: "邀请人数",
|
}),
|
||||||
width: "minmax(130px, 0.5fr)",
|
};
|
||||||
render: (summary) => `${formatNumber(summary.inviteCount)} 人`,
|
}
|
||||||
},
|
if (column.key === "status") {
|
||||||
{
|
return {
|
||||||
key: "validInviteCount",
|
...column,
|
||||||
label: "有效邀请人数",
|
filter: createOptionsColumnFilter({
|
||||||
width: "minmax(150px, 0.55fr)",
|
options: [["", "全部状态"], ...claimStatusOptions],
|
||||||
render: (summary) => `${formatNumber(summary.validInviteCount)} 人`,
|
placeholder: "搜索状态",
|
||||||
},
|
value: page.status,
|
||||||
{
|
onChange: page.changeStatus,
|
||||||
key: "totalRechargeCoinAmount",
|
}),
|
||||||
label: "邀请人数总充值",
|
};
|
||||||
width: "minmax(170px, 0.65fr)",
|
}
|
||||||
render: (summary) => formatNumber(summary.totalRechargeCoinAmount),
|
return column;
|
||||||
},
|
});
|
||||||
{
|
|
||||||
key: "totalRewardCoinAmount",
|
|
||||||
label: "总奖励金币",
|
|
||||||
width: "minmax(150px, 0.55fr)",
|
|
||||||
render: (summary) => formatNumber(summary.totalRewardCoinAmount),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -77,40 +153,23 @@ export function InviteActivityRewardPage() {
|
|||||||
onEdit={page.openConfigDrawer}
|
onEdit={page.openConfigDrawer}
|
||||||
onRefresh={page.reloadConfig}
|
onRefresh={page.reloadConfig}
|
||||||
/>
|
/>
|
||||||
<DataState error={page.summariesError} loading={page.summariesLoading} onRetry={page.reloadSummaries}>
|
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={page.summaries.items || []}
|
items={page.claims.items || []}
|
||||||
minWidth="1180px"
|
minWidth="1580px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
page: page.page,
|
page: page.page,
|
||||||
pageSize: page.summaries.pageSize || 50,
|
pageSize: page.claims.pageSize || 50,
|
||||||
total,
|
total,
|
||||||
onPageChange: page.setPage,
|
onPageChange: page.setPage,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
renderRowDetail={(summary) =>
|
rowKey={(claim) => claim.claimId}
|
||||||
expandedKey === summary.key ? <InviteeDetail key={`${summary.key}:detail`} summary={summary} /> : null
|
|
||||||
}
|
|
||||||
rowKey={(summary) => summary.key}
|
|
||||||
getRowProps={(summary) => ({
|
|
||||||
"aria-expanded": expandedKey === summary.key,
|
|
||||||
className: expandedKey === summary.key ? styles.rowExpanded : "",
|
|
||||||
role: "button",
|
|
||||||
tabIndex: 0,
|
|
||||||
onClick: () => toggleSummary(summary),
|
|
||||||
onKeyDown: (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
toggleSummary(summary);
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
@ -128,47 +187,35 @@ export function InviteActivityRewardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryUser({ expanded, summary }) {
|
function ClaimUser({ claim }) {
|
||||||
const user = summary.user || {};
|
const user = claim.user || {};
|
||||||
return (
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
||||||
<div className={styles.expandUser}>
|
|
||||||
<span className={styles.expandIcon} aria-hidden="true">
|
|
||||||
{expanded ? <KeyboardArrowDownOutlined fontSize="small" /> : <KeyboardArrowRightOutlined fontSize="small" />}
|
|
||||||
</span>
|
|
||||||
<AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || summary.userId }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function InviteeDetail({ summary }) {
|
function rewardTypeLabel(type) {
|
||||||
const invitees = summary.invitees || [];
|
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
|
||||||
return (
|
}
|
||||||
<div className={["admin-row", styles.detailRow].join(" ")}>
|
|
||||||
<div className={styles.detailCell}>
|
function claimStatusLabel(status) {
|
||||||
<div className={styles.inviteeTable}>
|
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
<div className={styles.inviteeHeader}>
|
}
|
||||||
<span>被邀请用户</span>
|
|
||||||
<span>充值</span>
|
function thresholdLabel(claim) {
|
||||||
<span>邀请时间</span>
|
if (claim.rewardType === "valid_invite") {
|
||||||
</div>
|
return `${formatNumber(claim.thresholdValidInviteCount)} 人`;
|
||||||
{invitees.length ? (
|
}
|
||||||
invitees.map((invitee) => (
|
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||||
<div className={styles.inviteeRow} key={`${summary.key}:${invitee.userId}`}>
|
return claim.tierId ? `关联用户 ${claim.tierId}` : "-";
|
||||||
<AdminUserIdentity
|
}
|
||||||
openInAppUserDetail
|
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
|
||||||
user={{ ...(invitee.user || {}), userId: invitee.user?.userId || invitee.userId }}
|
}
|
||||||
/>
|
|
||||||
<span>{formatNumber(invitee.rechargeCoinAmount)}</span>
|
function reachedLabel(claim) {
|
||||||
<span>{invitee.invitedAtMs ? formatMillis(invitee.invitedAtMs) : "-"}</span>
|
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||||
</div>
|
return "1 人";
|
||||||
))
|
}
|
||||||
) : (
|
const unit = claim.rewardType === "valid_invite" ? "人" : "金币";
|
||||||
<div className={styles.inviteeEmpty}>当前没有被邀请用户明细</div>
|
return `${formatNumber(claim.reachedValue)} ${unit}`;
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
|
|||||||
@ -20,12 +20,7 @@ import {
|
|||||||
tierRTPContribution,
|
tierRTPContribution,
|
||||||
updateStageTier,
|
updateStageTier,
|
||||||
} from "@/features/lucky-gift/configModel.js";
|
} from "@/features/lucky-gift/configModel.js";
|
||||||
import {
|
import { broadcastLevelOptions, rewardSourceOptions, stageOptions, optionLabel } from "@/features/lucky-gift/constants.js";
|
||||||
broadcastLevelOptions,
|
|
||||||
rewardSourceOptions,
|
|
||||||
stageOptions,
|
|
||||||
optionLabel,
|
|
||||||
} from "@/features/lucky-gift/constants.js";
|
|
||||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||||
|
|
||||||
const helpText = {
|
const helpText = {
|
||||||
@ -215,12 +210,7 @@ export function LuckyGiftConfigDrawer({
|
|||||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
|
||||||
disabled={disabled}
|
|
||||||
startIcon={<SaveOutlined fontSize="small" />}
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -239,10 +229,7 @@ function StageTierEditor({ disabled, form, setForm }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.stageStack}>
|
<div className={styles.stageStack}>
|
||||||
{stageOptions.map(([stageKey, stageLabel]) => {
|
{stageOptions.map(([stageKey, stageLabel]) => {
|
||||||
const stage = (form.stages || []).find((item) => item.stage === stageKey) || {
|
const stage = (form.stages || []).find((item) => item.stage === stageKey) || { stage: stageKey, tiers: [] };
|
||||||
stage: stageKey,
|
|
||||||
tiers: [],
|
|
||||||
};
|
|
||||||
const expectedRTP = stageExpectedRTP(stage);
|
const expectedRTP = stageExpectedRTP(stage);
|
||||||
const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0);
|
const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0);
|
||||||
const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0);
|
const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0);
|
||||||
@ -252,9 +239,7 @@ function StageTierEditor({ disabled, form, setForm }) {
|
|||||||
<div className={styles.stageHeader}>
|
<div className={styles.stageHeader}>
|
||||||
<FieldLabel help={helpText.stages} text={stageLabel} />
|
<FieldLabel help={helpText.stages} text={stageLabel} />
|
||||||
<div className={styles.stageMetrics}>
|
<div className={styles.stageMetrics}>
|
||||||
<span className={styles.stageTotal}>
|
<span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
|
||||||
概率 {formatPercent(stageProbabilityTotal(stage))}
|
|
||||||
</span>
|
|
||||||
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
|
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
|
||||||
期望 RTP {formatPercent(expectedRTP)}
|
期望 RTP {formatPercent(expectedRTP)}
|
||||||
</span>
|
</span>
|
||||||
@ -276,7 +261,7 @@ function StageTierEditor({ disabled, form, setForm }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateStages(setForm, (stages) =>
|
updateStages(setForm, (stages) =>
|
||||||
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey]),
|
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -318,10 +303,16 @@ function defaultStageTargetRTP(targetRTPPercent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) {
|
function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) {
|
||||||
const updateTier = (patch) =>
|
const updateTier = (patch) => updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch));
|
||||||
updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch));
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tierRow}>
|
<div className={styles.tierRow}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
label="奖档 ID"
|
||||||
|
value={tier.tierId}
|
||||||
|
onChange={(event) => updateTier({ tierId: event.target.value })}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -391,13 +382,7 @@ function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tie
|
|||||||
|
|
||||||
function SelectField({ disabled, label, onChange, options, value }) {
|
function SelectField({ disabled, label, onChange, options, value }) {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
select
|
|
||||||
disabled={disabled}
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
>
|
|
||||||
{options.map(([optionValue, optionLabelText]) => (
|
{options.map(([optionValue, optionLabelText]) => (
|
||||||
<MenuItem key={optionValue} value={optionValue}>
|
<MenuItem key={optionValue} value={optionValue}>
|
||||||
{optionLabelText || optionLabel(options, optionValue)}
|
{optionLabelText || optionLabel(options, optionValue)}
|
||||||
|
|||||||
@ -76,7 +76,6 @@ export function formFromConfig(config) {
|
|||||||
export function payloadFromLuckyGiftForm(form) {
|
export function payloadFromLuckyGiftForm(form) {
|
||||||
// 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节,
|
// 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节,
|
||||||
// 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。
|
// 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。
|
||||||
const stages = assignGeneratedTierIDs(normalizeStages(form.stages));
|
|
||||||
return {
|
return {
|
||||||
control_band_percent: decimal(form.controlBandPercent),
|
control_band_percent: decimal(form.controlBandPercent),
|
||||||
effective_from_ms: integer(form.effectiveFromMs),
|
effective_from_ms: integer(form.effectiveFromMs),
|
||||||
@ -93,7 +92,7 @@ export function payloadFromLuckyGiftForm(form) {
|
|||||||
pool_id: String(form.poolId || "").trim(),
|
pool_id: String(form.poolId || "").trim(),
|
||||||
pool_rate_percent: decimal(form.poolRatePercent),
|
pool_rate_percent: decimal(form.poolRatePercent),
|
||||||
settlement_window_wager: integer(form.settlementWindowWager),
|
settlement_window_wager: integer(form.settlementWindowWager),
|
||||||
stages: stages.map((stage) => ({
|
stages: normalizeStages(form.stages).map((stage) => ({
|
||||||
stage: stage.stage,
|
stage: stage.stage,
|
||||||
tiers: stage.tiers.map((item) => ({
|
tiers: stage.tiers.map((item) => ({
|
||||||
broadcast_level: item.broadcastLevel,
|
broadcast_level: item.broadcastLevel,
|
||||||
@ -127,25 +126,19 @@ export function previewForForm(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function addTierToStage(stages, stageKey) {
|
export function addTierToStage(stages, stageKey) {
|
||||||
return assignGeneratedTierIDs(
|
return stages.map((stage) => {
|
||||||
stages.map((stage) => {
|
if (stage.stage !== stageKey) {
|
||||||
if (stage.stage !== stageKey) {
|
return stage;
|
||||||
return stage;
|
}
|
||||||
}
|
const index = stage.tiers.length + 1;
|
||||||
const nextMultiplier = nextSuggestedTierMultiplier(stage.tiers);
|
return {
|
||||||
return {
|
...stage,
|
||||||
...stage,
|
tiers: [
|
||||||
tiers: [
|
...stage.tiers,
|
||||||
...stage.tiers,
|
tier(`${stage.stage}_${index}x`, "", "", { broadcastLevel: "none", rewardSource: "base_rtp" }),
|
||||||
tier(tierIDFromMultiplier(stage.stage, nextMultiplier), nextMultiplier, "", {
|
],
|
||||||
broadcastLevel: "none",
|
};
|
||||||
highWaterOnly: nextMultiplier >= 5,
|
});
|
||||||
rewardSource: "base_rtp",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTierFromStage(stages, stageKey, tierIndex) {
|
export function removeTierFromStage(stages, stageKey, tierIndex) {
|
||||||
@ -178,12 +171,8 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
|
|||||||
const enabledTiers = stage.tiers
|
const enabledTiers = stage.tiers
|
||||||
.map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item }))
|
.map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item }))
|
||||||
.filter((item) => item.tier.enabled !== false);
|
.filter((item) => item.tier.enabled !== false);
|
||||||
const baseTiers = enabledTiers.filter(
|
const baseTiers = enabledTiers.filter((item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier));
|
||||||
(item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier),
|
const positiveTiers = baseTiers.filter((item) => item.multiplier > 0).sort((left, right) => left.multiplier - right.multiplier);
|
||||||
);
|
|
||||||
const positiveTiers = baseTiers
|
|
||||||
.filter((item) => item.multiplier > 0)
|
|
||||||
.sort((left, right) => left.multiplier - right.multiplier);
|
|
||||||
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
|
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
|
||||||
if (enabledTiers.length === 0 || positiveTiers.length === 0) {
|
if (enabledTiers.length === 0 || positiveTiers.length === 0) {
|
||||||
return stage;
|
return stage;
|
||||||
@ -195,7 +184,7 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
|
|||||||
}
|
}
|
||||||
const fixedExpectedRTP = positiveTiers.reduce(
|
const fixedExpectedRTP = positiveTiers.reduce(
|
||||||
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
|
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
const remainingProbability = 100 - fixedProbability;
|
const remainingProbability = 100 - fixedProbability;
|
||||||
const targetExtraRTP = targetRTP - fixedExpectedRTP;
|
const targetExtraRTP = targetRTP - fixedExpectedRTP;
|
||||||
@ -238,75 +227,30 @@ export function tierRTPContribution(tier) {
|
|||||||
return expectedContributionPercent(tier.multiplier, tier.probabilityPercent);
|
return expectedContributionPercent(tier.multiplier, tier.probabilityPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assignGeneratedTierIDs(stages) {
|
|
||||||
return (stages || []).map((stage) => {
|
|
||||||
const used = new Map();
|
|
||||||
const tiers = (stage.tiers || []).map((item) => {
|
|
||||||
const baseID = tierIDFromMultiplier(stage.stage, item.multiplier);
|
|
||||||
const usedCount = used.get(baseID) || 0;
|
|
||||||
used.set(baseID, usedCount + 1);
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
tierId: usedCount === 0 ? baseID : `${baseID}_${usedCount + 1}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { ...stage, tiers };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultStages() {
|
function defaultStages() {
|
||||||
return stageOptions.map(([stage]) => ({ stage, tiers: defaultStageTiers[stage].map((item) => ({ ...item })) }));
|
return stageOptions.map(([stage]) => ({ stage, tiers: defaultStageTiers[stage].map((item) => ({ ...item })) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextSuggestedTierMultiplier(tiers) {
|
|
||||||
const usedMultipliers = (tiers || [])
|
|
||||||
.map((item) => decimal(item.multiplier))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0);
|
|
||||||
if (usedMultipliers.length === 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const maxMultiplier = Math.max(...usedMultipliers);
|
|
||||||
if (maxMultiplier >= 5) {
|
|
||||||
return maxMultiplier * 2;
|
|
||||||
}
|
|
||||||
if (maxMultiplier >= 2) {
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
return maxMultiplier + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierIDFromMultiplier(stage, multiplier) {
|
|
||||||
const value = decimal(multiplier);
|
|
||||||
if (value <= 0) {
|
|
||||||
return `${stage}_none`;
|
|
||||||
}
|
|
||||||
const multiplierText = formatDecimal(value).replace(".", "_");
|
|
||||||
return `${stage}_${multiplierText}x`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStages(stages) {
|
function normalizeStages(stages) {
|
||||||
// server 返回缺阶段时仍用默认三阶段补齐表单,保证运营看到的是完整发布形态;
|
// server 返回缺阶段时仍用默认三阶段补齐表单,保证运营看到的是完整发布形态;
|
||||||
// 保存时 schema 会再次校验概率合计和 0x 档,不让半成品配置进入发布请求。
|
// 保存时 schema 会再次校验概率合计和 0x 档,不让半成品配置进入发布请求。
|
||||||
const byStage = new Map((stages || []).map((stage) => [stage.stage, stage]));
|
const byStage = new Map((stages || []).map((stage) => [stage.stage, stage]));
|
||||||
return assignGeneratedTierIDs(
|
return stageOptions.map(([stage]) => {
|
||||||
stageOptions.map(([stage]) => {
|
const source = byStage.get(stage);
|
||||||
const source = byStage.get(stage);
|
const tiers = Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage];
|
||||||
const tiers =
|
return {
|
||||||
Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage];
|
stage,
|
||||||
return {
|
tiers: tiers.map((item) => ({
|
||||||
stage,
|
broadcastLevel: item.broadcastLevel || "none",
|
||||||
tiers: tiers.map((item) => ({
|
enabled: item.enabled !== false,
|
||||||
broadcastLevel: item.broadcastLevel || "none",
|
highWaterOnly: Boolean(item.highWaterOnly),
|
||||||
enabled: item.enabled !== false,
|
multiplier: formatDecimal(item.multiplier),
|
||||||
highWaterOnly: Boolean(item.highWaterOnly),
|
probabilityPercent: formatDecimal(item.probabilityPercent),
|
||||||
multiplier: formatDecimal(item.multiplier),
|
rewardSource: item.rewardSource || "base_rtp",
|
||||||
probabilityPercent: formatDecimal(item.probabilityPercent),
|
tierId: item.tierId || "",
|
||||||
rewardSource: item.rewardSource || "base_rtp",
|
})),
|
||||||
tierId: item.tierId || "",
|
};
|
||||||
})),
|
});
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneStages(stages) {
|
function cloneStages(stages) {
|
||||||
@ -315,10 +259,7 @@ function cloneStages(stages) {
|
|||||||
|
|
||||||
function positiveBaselineProbabilities(enabled) {
|
function positiveBaselineProbabilities(enabled) {
|
||||||
const probabilities = new Map();
|
const probabilities = new Map();
|
||||||
const floor =
|
const floor = enabled.length * minimumCorrectedProbabilityPercent <= 100 ? minimumCorrectedProbabilityPercent : 100 / enabled.length;
|
||||||
enabled.length * minimumCorrectedProbabilityPercent <= 100
|
|
||||||
? minimumCorrectedProbabilityPercent
|
|
||||||
: 100 / enabled.length;
|
|
||||||
for (const item of enabled) {
|
for (const item of enabled) {
|
||||||
probabilities.set(item.index, roundPercent(floor));
|
probabilities.set(item.index, roundPercent(floor));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,38 +56,7 @@ describe("lucky gift config payload", () => {
|
|||||||
const advanced = nextStages.find((stage) => stage.stage === "advanced");
|
const advanced = nextStages.find((stage) => stage.stage === "advanced");
|
||||||
|
|
||||||
expect(advanced.tiers).toHaveLength(5);
|
expect(advanced.tiers).toHaveLength(5);
|
||||||
expect(advanced.tiers[4]).toMatchObject({
|
expect(advanced.tiers[4]).toMatchObject({ broadcastLevel: "none", rewardSource: "base_rtp" });
|
||||||
broadcastLevel: "none",
|
|
||||||
highWaterOnly: true,
|
|
||||||
multiplier: 10,
|
|
||||||
rewardSource: "base_rtp",
|
|
||||||
tierId: "advanced_10x",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regenerates unique tier ids when building submit payload", () => {
|
|
||||||
const form = emptyLuckyGiftForm();
|
|
||||||
const advanced = form.stages.find((stage) => stage.stage === "advanced");
|
|
||||||
advanced.tiers = [
|
|
||||||
...advanced.tiers,
|
|
||||||
{
|
|
||||||
...advanced.tiers[advanced.tiers.length - 1],
|
|
||||||
multiplier: 5,
|
|
||||||
probabilityPercent: 0,
|
|
||||||
tierId: "manual_duplicate",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const payload = payloadFromLuckyGiftForm(form);
|
|
||||||
const advancedPayload = payload.stages.find((stage) => stage.stage === "advanced");
|
|
||||||
|
|
||||||
expect(advancedPayload.tiers.map((tier) => tier.tier_id)).toEqual([
|
|
||||||
"advanced_none",
|
|
||||||
"advanced_1x",
|
|
||||||
"advanced_2x",
|
|
||||||
"advanced_5x",
|
|
||||||
"advanced_5x_2",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => {
|
test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => {
|
||||||
|
|||||||
@ -18,27 +18,4 @@ describe("lucky gift config schema", () => {
|
|||||||
expect(result.error.issues.map((issue) => issue.message)).toContain("启用奖档概率合计必须等于 100%");
|
expect(result.error.issues.map((issue) => issue.message)).toContain("启用奖档概率合计必须等于 100%");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not require manually maintained tier ids before submit", () => {
|
|
||||||
const form = emptyLuckyGiftForm();
|
|
||||||
const advanced = form.stages.find((stage: { stage: string }) => stage.stage === "advanced");
|
|
||||||
if (!advanced) {
|
|
||||||
throw new Error("advanced stage missing");
|
|
||||||
}
|
|
||||||
advanced.tiers[0].tierId = "";
|
|
||||||
advanced.tiers = [
|
|
||||||
...advanced.tiers,
|
|
||||||
{
|
|
||||||
...advanced.tiers[advanced.tiers.length - 1],
|
|
||||||
enabled: false,
|
|
||||||
multiplier: 10,
|
|
||||||
probabilityPercent: 0,
|
|
||||||
tierId: "advanced_5x",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = luckyGiftConfigFormSchema.safeParse(form);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const stageTierSchema = z.object({
|
|||||||
multiplier: z.coerce.number().min(0, "倍率不能小于 0"),
|
multiplier: z.coerce.number().min(0, "倍率不能小于 0"),
|
||||||
probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"),
|
probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"),
|
||||||
rewardSource: z.enum(rewardSources),
|
rewardSource: z.enum(rewardSources),
|
||||||
tierId: z.string().optional().default(""),
|
tierId: z.string().trim().min(1, "奖档 ID 不能为空").max(96, "奖档 ID 不能超过 96 个字符"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageSchema = z.object({
|
const stageSchema = z.object({
|
||||||
@ -31,14 +31,8 @@ export const luckyGiftConfigFormSchema = z
|
|||||||
effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"),
|
effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(0, "参考价格必须大于 0"),
|
giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(0, "参考价格必须大于 0"),
|
||||||
noviceMaxEquivalentDraws: z.coerce
|
noviceMaxEquivalentDraws: z.coerce.number().int("新手阶段等价抽数必须是整数").min(0, "新手阶段等价抽数不能小于 0"),
|
||||||
.number()
|
normalMaxEquivalentDraws: z.coerce.number().int("正常阶段等价抽数必须是整数").min(0, "正常阶段等价抽数不能小于 0"),
|
||||||
.int("新手阶段等价抽数必须是整数")
|
|
||||||
.min(0, "新手阶段等价抽数不能小于 0"),
|
|
||||||
normalMaxEquivalentDraws: z.coerce
|
|
||||||
.number()
|
|
||||||
.int("正常阶段等价抽数必须是整数")
|
|
||||||
.min(0, "正常阶段等价抽数不能小于 0"),
|
|
||||||
maxSinglePayout: z.coerce.number().int("单次返奖上限必须是整数").gt(0, "单次返奖上限必须大于 0"),
|
maxSinglePayout: z.coerce.number().int("单次返奖上限必须是整数").gt(0, "单次返奖上限必须大于 0"),
|
||||||
userHourlyPayoutCap: z.coerce.number().int("用户小时上限必须是整数").gt(0, "用户小时上限必须大于 0"),
|
userHourlyPayoutCap: z.coerce.number().int("用户小时上限必须是整数").gt(0, "用户小时上限必须大于 0"),
|
||||||
userDailyPayoutCap: z.coerce.number().int("用户每日上限必须是整数").gt(0, "用户每日上限必须大于 0"),
|
userDailyPayoutCap: z.coerce.number().int("用户每日上限必须是整数").gt(0, "用户每日上限必须大于 0"),
|
||||||
@ -103,10 +97,7 @@ export const luckyGiftConfigFormSchema = z
|
|||||||
if (!tier.enabled || tier.rewardSource !== "base_rtp") {
|
if (!tier.enabled || tier.rewardSource !== "base_rtp") {
|
||||||
return sum;
|
return sum;
|
||||||
}
|
}
|
||||||
return (
|
return sum + Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale);
|
||||||
sum +
|
|
||||||
Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale)
|
|
||||||
);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
const expectedRTP = expectedRTPPPM / 10_000;
|
const expectedRTP = expectedRTPPPM / 10_000;
|
||||||
const lowerRTPPPM = percentToPPM(value.targetRtpPercent - value.controlBandPercent);
|
const lowerRTPPPM = percentToPPM(value.targetRtpPercent - value.controlBandPercent);
|
||||||
|
|||||||
@ -1,492 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { listCoinLedger } from "@/features/operations/api";
|
|
||||||
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";
|
|
||||||
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 styles from "@/features/operations/operations.module.css";
|
|
||||||
|
|
||||||
const pageSize = 50;
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
key: "user",
|
|
||||||
label: "用户",
|
|
||||||
width: "minmax(260px, 1.35fr)",
|
|
||||||
render: (entry) => <UserCell entry={entry} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "amount",
|
|
||||||
label: "数量",
|
|
||||||
width: "minmax(120px, 0.65fr)",
|
|
||||||
render: (entry) => <AmountValue entry={entry} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bizType",
|
|
||||||
label: "类型",
|
|
||||||
width: "minmax(150px, 0.8fr)",
|
|
||||||
render: (entry) => bizTypeLabel(entry.bizType),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "balance",
|
|
||||||
label: "余额",
|
|
||||||
width: "minmax(120px, 0.65fr)",
|
|
||||||
render: (entry) => formatNumber(entry.availableAfter),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAtMs",
|
|
||||||
label: "时间",
|
|
||||||
width: "minmax(170px, 0.85fr)",
|
|
||||||
render: (entry) => <TimeText value={entry.createdAtMs} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
|
|
||||||
|
|
||||||
const metadataLabels = {
|
|
||||||
amount: "数量",
|
|
||||||
app_code: "App",
|
|
||||||
applied_at_ms: "入账时间",
|
|
||||||
asset_type: "资产类型",
|
|
||||||
available_delta: "可用变化",
|
|
||||||
balance_after: "余额",
|
|
||||||
billing_receipt_id: "账单 ID",
|
|
||||||
charge_amount: "扣费数量",
|
|
||||||
charge_asset_type: "扣费资产",
|
|
||||||
coin_amount: "金币数量",
|
|
||||||
coin_price: "单价",
|
|
||||||
coin_spent: "消耗金币",
|
|
||||||
counts_as_seller_recharge: "计入币商充值",
|
|
||||||
cycle_key: "周期",
|
|
||||||
evidence_ref: "凭证",
|
|
||||||
game_id: "游戏 ID",
|
|
||||||
gift_count: "礼物数量",
|
|
||||||
gift_id: "礼物 ID",
|
|
||||||
gift_name: "礼物名称",
|
|
||||||
heat_value: "热度",
|
|
||||||
operator_user_id: "操作人",
|
|
||||||
op_type: "操作类型",
|
|
||||||
paid_amount_micro: "支付金额",
|
|
||||||
paid_currency_code: "支付币种",
|
|
||||||
payment_ref: "支付凭证",
|
|
||||||
platform_code: "游戏平台",
|
|
||||||
price_version: "价格版本",
|
|
||||||
provider_order_id: "游戏订单",
|
|
||||||
provider_round_id: "游戏局号",
|
|
||||||
reason: "原因",
|
|
||||||
recharge_currency_code: "充值币种",
|
|
||||||
recharge_policy_coin_amount: "策略金币",
|
|
||||||
recharge_policy_id: "充值策略 ID",
|
|
||||||
recharge_policy_usd_minor_amount: "策略 USD 金额",
|
|
||||||
recharge_policy_version: "充值策略版本",
|
|
||||||
recharge_usd_minor: "充值 USD 金额",
|
|
||||||
room_id: "房间 ID",
|
|
||||||
seller_asset_type: "币商资产",
|
|
||||||
seller_balance_after: "币商余额",
|
|
||||||
seller_region_id: "币商区域",
|
|
||||||
seller_user_id: "币商用户",
|
|
||||||
sender_user_id: "赠送人",
|
|
||||||
sort_order: "排序",
|
|
||||||
stock_type: "进货类型",
|
|
||||||
target_asset_type: "目标资产",
|
|
||||||
target_balance_after: "接收方余额",
|
|
||||||
target_region_id: "接收方区域",
|
|
||||||
target_user_id: "接收用户",
|
|
||||||
task_id: "任务 ID",
|
|
||||||
task_type: "任务类型",
|
|
||||||
user_id: "用户 ID",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
|
||||||
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
|
||||||
const [userKeyword, setUserKeyword] = useState("");
|
|
||||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
|
||||||
|
|
||||||
const filters = useMemo(
|
|
||||||
() => ({
|
|
||||||
end_at_ms: timeRange.endMs || "",
|
|
||||||
start_at_ms: timeRange.startMs || "",
|
|
||||||
user_keyword: fixedUserId || userKeyword,
|
|
||||||
}),
|
|
||||||
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
|
||||||
);
|
|
||||||
const query = usePaginatedQuery({
|
|
||||||
errorMessage: "获取金币流水失败",
|
|
||||||
fetcher: listCoinLedger,
|
|
||||||
filters,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
queryKey: ["coin-ledger", filters, page],
|
|
||||||
});
|
|
||||||
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 changeUserKeyword = (value) => {
|
|
||||||
setUserKeyword(value);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const changeTimeRange = (nextRange) => {
|
|
||||||
setTimeRange(nextRange);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const resetFilters = () => {
|
|
||||||
setUserKeyword("");
|
|
||||||
setTimeRange({ endMs: "", startMs: "" });
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
const closeDetail = () => {
|
|
||||||
setSelectedEntry(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.ledgerTableShell}>
|
|
||||||
<AdminListToolbar
|
|
||||||
filters={
|
|
||||||
<>
|
|
||||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
|
||||||
<AdminFilterResetButton disabled={!hasMutableFilters} onClick={resetFilters} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
|
||||||
<AdminListBody>
|
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
|
||||||
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
|
||||||
items={items}
|
|
||||||
minWidth={fixedUserId ? "620px" : "900px"}
|
|
||||||
pagination={
|
|
||||||
total > 0
|
|
||||||
? {
|
|
||||||
page,
|
|
||||||
pageSize: data.pageSize || pageSize,
|
|
||||||
total,
|
|
||||||
onPageChange: setPage,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rowKey={coinLedgerRowKey}
|
|
||||||
/>
|
|
||||||
</AdminListBody>
|
|
||||||
</DataState>
|
|
||||||
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function coinLedgerRowKey(entry) {
|
|
||||||
return entry.entryId || entry.transactionId || `${entry.userId || "user"}-${entry.createdAtMs || "time"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coinLedgerRowProps(entry, onOpen) {
|
|
||||||
const user = entry.user || {};
|
|
||||||
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
|
|
||||||
return {
|
|
||||||
"aria-label": `查看 ${userLabel} 的金币流水详情`,
|
|
||||||
className: styles.clickableRow,
|
|
||||||
role: "button",
|
|
||||||
tabIndex: 0,
|
|
||||||
onClick: () => onOpen(entry),
|
|
||||||
onKeyDown: (event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
onOpen(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
|
||||||
const metadata = metadataObject(entry?.metadata);
|
|
||||||
const contextRows = entry ? businessContextRows(entry, metadata) : [];
|
|
||||||
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
|
|
||||||
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
|
|
||||||
const user = entry?.user || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
|
||||||
{entry ? (
|
|
||||||
<>
|
|
||||||
<header className={styles.detailHero}>
|
|
||||||
<AdminUserIdentity
|
|
||||||
openInAppUserDetail
|
|
||||||
user={{ ...user, userId: user.userId || entry.userId }}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
<AmountValue entry={entry} />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<DetailSection
|
|
||||||
items={[
|
|
||||||
detailRow("交易 ID", entry.transactionId),
|
|
||||||
detailRow("命令 ID", entry.commandId),
|
|
||||||
detailRow("外部单号", entry.externalRef),
|
|
||||||
detailRow("流水 ID", entry.entryId),
|
|
||||||
detailRow("业务类型", bizTypeLabel(entry.bizType)),
|
|
||||||
detailRow("方向", directionLabel(entry.direction)),
|
|
||||||
detailRow("发生时间", <TimeText value={entry.createdAtMs} />),
|
|
||||||
]}
|
|
||||||
title="交易信息"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailSection
|
|
||||||
items={[
|
|
||||||
detailRow("流水数量", formatSignedAmount(entry)),
|
|
||||||
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
|
|
||||||
detailRow("可用余额", formatNumber(entry.availableAfter)),
|
|
||||||
]}
|
|
||||||
title="金额变化"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailSection items={contextRows} title="业务上下文" />
|
|
||||||
<DetailSection items={metadataRows} title="扩展明细" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</SideDrawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserCell({ entry }) {
|
|
||||||
const user = entry.user || {};
|
|
||||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AmountValue({ entry }) {
|
|
||||||
const expense = entry.direction === "expense";
|
|
||||||
return (
|
|
||||||
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
|
||||||
{formatSignedAmount(entry)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailSection({ items, title }) {
|
|
||||||
const visibleItems = items.filter((item) => hasDetailValue(item.value));
|
|
||||||
if (!visibleItems.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className={styles.detailSection}>
|
|
||||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
|
||||||
<div className={styles.detailGrid}>
|
|
||||||
{visibleItems.map((item) => (
|
|
||||||
<DetailItem key={item.label} label={item.label} value={item.value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailItem({ label, value }) {
|
|
||||||
return (
|
|
||||||
<div className={styles.detailItem}>
|
|
||||||
<span className={styles.detailLabel}>{label}</span>
|
|
||||||
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function businessContextRows(entry, metadata) {
|
|
||||||
if (entry.bizType === "gift_debit") {
|
|
||||||
return compactRows([
|
|
||||||
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
|
|
||||||
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
|
|
||||||
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
|
|
||||||
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
|
||||||
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
|
|
||||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
|
||||||
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
|
|
||||||
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
|
|
||||||
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
|
|
||||||
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(entry.bizType || "").startsWith("game_")) {
|
|
||||||
return compactRows([
|
|
||||||
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
|
|
||||||
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
|
|
||||||
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
|
|
||||||
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
|
|
||||||
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
|
|
||||||
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
|
|
||||||
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.bizType === "coin_seller_transfer") {
|
|
||||||
return compactRows([
|
|
||||||
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
|
|
||||||
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
|
||||||
metadataRow("转账金额", metadata, ["amount"]),
|
|
||||||
metadataRow("原因", metadata, ["reason"]),
|
|
||||||
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
|
|
||||||
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
|
|
||||||
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
|
|
||||||
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return compactRows([
|
|
||||||
metadataRow(
|
|
||||||
"关联用户",
|
|
||||||
metadata,
|
|
||||||
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
|
|
||||||
entry.counterpartyUserId,
|
|
||||||
),
|
|
||||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
|
||||||
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
|
||||||
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
|
||||||
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
|
|
||||||
metadataRow("原因", metadata, ["reason"]),
|
|
||||||
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
|
|
||||||
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadataDetailRows(metadata, usedMetadataKeys) {
|
|
||||||
return Object.entries(metadata)
|
|
||||||
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
|
|
||||||
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadataRow(label, metadata, keys, fallback) {
|
|
||||||
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailRow(label, value, metadataKeys = []) {
|
|
||||||
return { label, metadataKeys, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactRows(rows) {
|
|
||||||
return rows.filter((row) => hasDetailValue(row.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueFromMetadata(metadata, keys) {
|
|
||||||
for (const key of keys) {
|
|
||||||
if (hasDetailValue(metadata[key])) {
|
|
||||||
return metadata[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstValue(...values) {
|
|
||||||
return values.find((value) => hasDetailValue(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadataObject(value) {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMetadataValue(value) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayDetailValue(value) {
|
|
||||||
if (!hasDetailValue(value)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
if (typeof value === "object") {
|
|
||||||
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasDetailValue(value) {
|
|
||||||
return value === 0 || value === false || Boolean(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedAmount(entry) {
|
|
||||||
const sign = entry.direction === "expense" ? "-" : "+";
|
|
||||||
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
|
|
||||||
return `${sign}${formatNumber(amount)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedDelta(value) {
|
|
||||||
if (!hasDetailValue(value)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const numberValue = Number(value);
|
|
||||||
if (Number.isNaN(numberValue)) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
if (value === 0 || value) {
|
|
||||||
return Number(value).toLocaleString("zh-CN");
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function directionLabel(value) {
|
|
||||||
const labels = {
|
|
||||||
expense: "支出",
|
|
||||||
income: "收入",
|
|
||||||
};
|
|
||||||
return labels[value] || value || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadataLabel(key) {
|
|
||||||
return metadataLabels[key] || 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 || "-";
|
|
||||||
}
|
|
||||||
@ -30,9 +30,8 @@ const ledgerTypeLabels = {
|
|||||||
|
|
||||||
const bizTypeLabels = {
|
const bizTypeLabels = {
|
||||||
coin_seller_coin_compensation: "金币补偿",
|
coin_seller_coin_compensation: "金币补偿",
|
||||||
coin_seller_stock_purchase: "USDT进货",
|
coin_seller_stock_purchase: "币商进货",
|
||||||
coin_seller_transfer: "币商转用户",
|
coin_seller_transfer: "币商转用户",
|
||||||
manual_credit: "金币增加",
|
|
||||||
salary_transfer_to_coin_seller: "工资转币商",
|
salary_transfer_to_coin_seller: "工资转币商",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -51,20 +50,8 @@ const baseColumns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "amount",
|
key: "amount",
|
||||||
label: "金币变动",
|
|
||||||
render: (entry) => <AmountValue entry={entry} />,
|
|
||||||
width: "minmax(130px, 0.65fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "transferUsdMinor",
|
|
||||||
label: "转账金额",
|
label: "转账金额",
|
||||||
render: (entry) => <SalaryTransferAmount entry={entry} />,
|
render: (entry) => <AmountValue entry={entry} />,
|
||||||
width: "minmax(130px, 0.65fr)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "paidAmountMicro",
|
|
||||||
label: "USDT数量",
|
|
||||||
render: (entry) => <PaidUSDTAmount entry={entry} />,
|
|
||||||
width: "minmax(130px, 0.65fr)",
|
width: "minmax(130px, 0.65fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -210,7 +197,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
emptyLabel="当前无币商流水"
|
emptyLabel="当前无币商流水"
|
||||||
items={items}
|
items={items}
|
||||||
minWidth={fixedSellerUserId ? "1130px" : "1380px"}
|
minWidth={fixedSellerUserId ? "890px" : "1160px"}
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -260,44 +247,11 @@ function AmountValue({ entry }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ledgerTypeLabel(entry) {
|
function ledgerTypeLabel(entry) {
|
||||||
if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) {
|
return (
|
||||||
return "金币扣除";
|
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-"
|
||||||
}
|
);
|
||||||
return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function SalaryTransferAmount({ entry }) {
|
|
||||||
if (entry.bizType !== "salary_transfer_to_coin_seller" && entry.ledgerType !== "salary_transfer_received") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const amountMinor = Number(entry.transferUsdMinor || entry.metadata?.salary_usd_minor || 0);
|
|
||||||
return amountMinor > 0 ? `$${formatUSDMinor(amountMinor)}` : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaidUSDTAmount({ entry }) {
|
|
||||||
const stockType = entry.stockType || entry.metadata?.stock_type;
|
|
||||||
if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
|
|
||||||
return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUSDMinor(value) {
|
|
||||||
return (Number(value || 0) / 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUSDTMicro(value) {
|
|
||||||
const amount = Number(value || 0);
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
const whole = Math.trunc(amount / 1_000_000);
|
|
||||||
const fraction = Math.trunc(Math.abs(amount % 1_000_000));
|
|
||||||
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
|
|
||||||
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,488 @@
|
|||||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
import { useMemo, useState } from "react";
|
||||||
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
import { listCoinLedger } from "@/features/operations/api";
|
||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
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";
|
||||||
|
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 styles from "@/features/operations/operations.module.css";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户",
|
||||||
|
width: "minmax(260px, 1.35fr)",
|
||||||
|
render: (entry) => <UserCell entry={entry} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "amount",
|
||||||
|
label: "数量",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (entry) => <AmountValue entry={entry} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bizType",
|
||||||
|
label: "类型",
|
||||||
|
width: "minmax(150px, 0.8fr)",
|
||||||
|
render: (entry) => bizTypeLabel(entry.bizType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "balance",
|
||||||
|
label: "余额",
|
||||||
|
width: "minmax(120px, 0.65fr)",
|
||||||
|
render: (entry) => formatNumber(entry.availableAfter),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAtMs",
|
||||||
|
label: "时间",
|
||||||
|
width: "minmax(170px, 0.85fr)",
|
||||||
|
render: (entry) => <TimeText value={entry.createdAtMs} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
|
||||||
|
|
||||||
|
const metadataLabels = {
|
||||||
|
amount: "数量",
|
||||||
|
app_code: "App",
|
||||||
|
applied_at_ms: "入账时间",
|
||||||
|
asset_type: "资产类型",
|
||||||
|
available_delta: "可用变化",
|
||||||
|
balance_after: "余额",
|
||||||
|
billing_receipt_id: "账单 ID",
|
||||||
|
charge_amount: "扣费数量",
|
||||||
|
charge_asset_type: "扣费资产",
|
||||||
|
coin_amount: "金币数量",
|
||||||
|
coin_price: "单价",
|
||||||
|
coin_spent: "消耗金币",
|
||||||
|
counts_as_seller_recharge: "计入币商充值",
|
||||||
|
cycle_key: "周期",
|
||||||
|
evidence_ref: "凭证",
|
||||||
|
game_id: "游戏 ID",
|
||||||
|
gift_count: "礼物数量",
|
||||||
|
gift_id: "礼物 ID",
|
||||||
|
gift_name: "礼物名称",
|
||||||
|
heat_value: "热度",
|
||||||
|
operator_user_id: "操作人",
|
||||||
|
op_type: "操作类型",
|
||||||
|
paid_amount_micro: "支付金额",
|
||||||
|
paid_currency_code: "支付币种",
|
||||||
|
payment_ref: "支付凭证",
|
||||||
|
platform_code: "游戏平台",
|
||||||
|
price_version: "价格版本",
|
||||||
|
provider_order_id: "游戏订单",
|
||||||
|
provider_round_id: "游戏局号",
|
||||||
|
reason: "原因",
|
||||||
|
recharge_currency_code: "充值币种",
|
||||||
|
recharge_policy_coin_amount: "策略金币",
|
||||||
|
recharge_policy_id: "充值策略 ID",
|
||||||
|
recharge_policy_usd_minor_amount: "策略 USD 金额",
|
||||||
|
recharge_policy_version: "充值策略版本",
|
||||||
|
recharge_usd_minor: "充值 USD 金额",
|
||||||
|
room_id: "房间 ID",
|
||||||
|
seller_asset_type: "币商资产",
|
||||||
|
seller_balance_after: "币商余额",
|
||||||
|
seller_region_id: "币商区域",
|
||||||
|
seller_user_id: "币商用户",
|
||||||
|
sender_user_id: "赠送人",
|
||||||
|
sort_order: "排序",
|
||||||
|
stock_type: "进货类型",
|
||||||
|
target_asset_type: "目标资产",
|
||||||
|
target_balance_after: "接收方余额",
|
||||||
|
target_region_id: "接收方区域",
|
||||||
|
target_user_id: "接收用户",
|
||||||
|
task_id: "任务 ID",
|
||||||
|
task_type: "任务类型",
|
||||||
|
user_id: "用户 ID",
|
||||||
|
};
|
||||||
|
|
||||||
export function CoinLedgerPage() {
|
export function CoinLedgerPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||||
|
const [userKeyword, setUserKeyword] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
end_at_ms: timeRange.endMs || "",
|
||||||
|
start_at_ms: timeRange.startMs || "",
|
||||||
|
user_keyword: userKeyword,
|
||||||
|
}),
|
||||||
|
[timeRange.endMs, timeRange.startMs, userKeyword],
|
||||||
|
);
|
||||||
|
const query = usePaginatedQuery({
|
||||||
|
errorMessage: "获取金币流水失败",
|
||||||
|
fetcher: listCoinLedger,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["coin-ledger", filters, page],
|
||||||
|
});
|
||||||
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||||
|
const items = data.items || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
const changeFilter = (setter) => (value) => {
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeTimeRange = (nextRange) => {
|
||||||
|
setTimeRange(nextRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setUserKeyword("");
|
||||||
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const closeDetail = () => {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableColumns = columns.map((column) =>
|
||||||
|
column.key === "user"
|
||||||
|
? {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "长 ID / 短 ID / 昵称",
|
||||||
|
value: userKeyword,
|
||||||
|
onChange: changeFilter(setUserKeyword),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: column,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
<CoinLedgerTable />
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
|
<AdminFilterResetButton
|
||||||
|
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
|
||||||
|
onClick={resetFilters}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
||||||
|
items={items}
|
||||||
|
minWidth="900px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(entry) => entry.entryId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coinLedgerRowProps(entry, onOpen) {
|
||||||
|
const user = entry.user || {};
|
||||||
|
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
|
||||||
|
return {
|
||||||
|
"aria-label": `查看 ${userLabel} 的金币流水详情`,
|
||||||
|
className: styles.clickableRow,
|
||||||
|
role: "button",
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: () => onOpen(entry),
|
||||||
|
onKeyDown: (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpen(entry);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
||||||
|
const metadata = metadataObject(entry?.metadata);
|
||||||
|
const contextRows = entry ? businessContextRows(entry, metadata) : [];
|
||||||
|
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
|
||||||
|
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
|
||||||
|
const user = entry?.user || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
||||||
|
{entry ? (
|
||||||
|
<>
|
||||||
|
<header className={styles.detailHero}>
|
||||||
|
<AdminUserIdentity
|
||||||
|
openInAppUserDetail
|
||||||
|
user={{ ...user, userId: user.userId || entry.userId }}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<AmountValue entry={entry} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DetailSection
|
||||||
|
items={[
|
||||||
|
detailRow("交易 ID", entry.transactionId),
|
||||||
|
detailRow("命令 ID", entry.commandId),
|
||||||
|
detailRow("外部单号", entry.externalRef),
|
||||||
|
detailRow("流水 ID", entry.entryId),
|
||||||
|
detailRow("业务类型", bizTypeLabel(entry.bizType)),
|
||||||
|
detailRow("方向", directionLabel(entry.direction)),
|
||||||
|
detailRow("发生时间", <TimeText value={entry.createdAtMs} />),
|
||||||
|
]}
|
||||||
|
title="交易信息"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailSection
|
||||||
|
items={[
|
||||||
|
detailRow("流水数量", formatSignedAmount(entry)),
|
||||||
|
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
|
||||||
|
detailRow("可用余额", formatNumber(entry.availableAfter)),
|
||||||
|
]}
|
||||||
|
title="金额变化"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailSection items={contextRows} title="业务上下文" />
|
||||||
|
<DetailSection items={metadataRows} title="扩展明细" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</SideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCell({ entry }) {
|
||||||
|
const user = entry.user || {};
|
||||||
|
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountValue({ entry }) {
|
||||||
|
const expense = entry.direction === "expense";
|
||||||
|
return (
|
||||||
|
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
||||||
|
{formatSignedAmount(entry)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSection({ items, title }) {
|
||||||
|
const visibleItems = items.filter((item) => hasDetailValue(item.value));
|
||||||
|
if (!visibleItems.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
{visibleItems.map((item) => (
|
||||||
|
<DetailItem key={item.label} label={item.label} value={item.value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.detailItem}>
|
||||||
|
<span className={styles.detailLabel}>{label}</span>
|
||||||
|
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function businessContextRows(entry, metadata) {
|
||||||
|
if (entry.bizType === "gift_debit") {
|
||||||
|
return compactRows([
|
||||||
|
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
|
||||||
|
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
|
||||||
|
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
|
||||||
|
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||||
|
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
|
||||||
|
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||||
|
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
|
||||||
|
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
|
||||||
|
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
|
||||||
|
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(entry.bizType || "").startsWith("game_")) {
|
||||||
|
return compactRows([
|
||||||
|
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
|
||||||
|
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
|
||||||
|
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
|
||||||
|
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
|
||||||
|
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
|
||||||
|
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
|
||||||
|
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.bizType === "coin_seller_transfer") {
|
||||||
|
return compactRows([
|
||||||
|
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
|
||||||
|
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||||
|
metadataRow("转账金额", metadata, ["amount"]),
|
||||||
|
metadataRow("原因", metadata, ["reason"]),
|
||||||
|
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
|
||||||
|
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
|
||||||
|
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
|
||||||
|
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compactRows([
|
||||||
|
metadataRow(
|
||||||
|
"关联用户",
|
||||||
|
metadata,
|
||||||
|
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
|
||||||
|
entry.counterpartyUserId,
|
||||||
|
),
|
||||||
|
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||||
|
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
||||||
|
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
||||||
|
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
|
||||||
|
metadataRow("原因", metadata, ["reason"]),
|
||||||
|
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
|
||||||
|
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataDetailRows(metadata, usedMetadataKeys) {
|
||||||
|
return Object.entries(metadata)
|
||||||
|
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
|
||||||
|
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataRow(label, metadata, keys, fallback) {
|
||||||
|
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailRow(label, value, metadataKeys = []) {
|
||||||
|
return { label, metadataKeys, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactRows(rows) {
|
||||||
|
return rows.filter((row) => hasDetailValue(row.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFromMetadata(metadata, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (hasDetailValue(metadata[key])) {
|
||||||
|
return metadata[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValue(...values) {
|
||||||
|
return values.find((value) => hasDetailValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataObject(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadataValue(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDetailValue(value) {
|
||||||
|
if (!hasDetailValue(value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetailValue(value) {
|
||||||
|
return value === 0 || value === false || Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedAmount(entry) {
|
||||||
|
const sign = entry.direction === "expense" ? "-" : "+";
|
||||||
|
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
|
||||||
|
return `${sign}${formatNumber(amount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedDelta(value) {
|
||||||
|
if (!hasDetailValue(value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const numberValue = Number(value);
|
||||||
|
if (Number.isNaN(numberValue)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
if (value === 0 || value) {
|
||||||
|
return Number(value).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionLabel(value) {
|
||||||
|
const labels = {
|
||||||
|
expense: "支出",
|
||||||
|
income: "收入",
|
||||||
|
};
|
||||||
|
return labels[value] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataLabel(key) {
|
||||||
|
return metadataLabels[key] || 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 || "-";
|
||||||
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export interface RedPacketClaimDto {
|
|||||||
claimId: string;
|
claimId: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
packetId: string;
|
packetId: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
walletTransactionId?: string;
|
walletTransactionId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -43,7 +43,7 @@ export interface RedPacketDto {
|
|||||||
appCode?: string;
|
appCode?: string;
|
||||||
packetId: string;
|
packetId: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
senderUserId: string;
|
senderUserId: number;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
regionId: number;
|
regionId: number;
|
||||||
packetType: string;
|
packetType: string;
|
||||||
@ -139,7 +139,7 @@ function normalizePacket(item: RawPacket): RedPacketDto {
|
|||||||
appCode: stringValue(item.appCode ?? item.app_code),
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
packetId: stringValue(item.packetId ?? item.packet_id),
|
packetId: stringValue(item.packetId ?? item.packet_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
senderUserId: stringValue(item.senderUserId ?? item.sender_user_id),
|
senderUserId: numberValue(item.senderUserId ?? item.sender_user_id),
|
||||||
roomId: stringValue(item.roomId ?? item.room_id),
|
roomId: stringValue(item.roomId ?? item.room_id),
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
packetType: stringValue(item.packetType ?? item.packet_type),
|
packetType: stringValue(item.packetType ?? item.packet_type),
|
||||||
@ -164,7 +164,7 @@ function normalizeClaim(item: RawClaim): RedPacketClaimDto {
|
|||||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
packetId: stringValue(item.packetId ?? item.packet_id),
|
packetId: stringValue(item.packetId ?? item.packet_id),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
amount: numberValue(item.amount),
|
amount: numberValue(item.amount),
|
||||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export interface RegistrationRewardConfigPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistrationRewardClaimUserDto {
|
export interface RegistrationRewardClaimUserDto {
|
||||||
userId?: string;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
@ -34,7 +34,7 @@ export interface RegistrationRewardClaimUserDto {
|
|||||||
export interface RegistrationRewardClaimDto {
|
export interface RegistrationRewardClaimDto {
|
||||||
claimId: string;
|
claimId: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
user?: RegistrationRewardClaimUserDto;
|
user?: RegistrationRewardClaimUserDto;
|
||||||
rewardDay?: string;
|
rewardDay?: string;
|
||||||
rewardType?: string;
|
rewardType?: string;
|
||||||
@ -109,9 +109,9 @@ function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
|
|||||||
return {
|
return {
|
||||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
user: {
|
user: {
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||||
|
|||||||
@ -166,7 +166,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
commandId: "resource-group-grant-test",
|
commandId: "resource-group-grant-test",
|
||||||
groupId: 22,
|
groupId: 22,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
targetUserId: "1001",
|
targetUserId: 1001,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
@ -258,7 +258,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
});
|
});
|
||||||
expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group");
|
expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group");
|
||||||
expect(grantGroupInit?.method).toBe("POST");
|
expect(grantGroupInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" });
|
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
||||||
|
|||||||
@ -209,7 +209,7 @@ export interface ResourceGrantOperatorDto {
|
|||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
userId?: string;
|
userId?: number | string;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +218,7 @@ export interface ResourceGrantUserDto {
|
|||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
userId?: string;
|
userId?: number | string;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ export interface ResourceGrantDto {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
targetUser?: ResourceGrantUserDto;
|
targetUser?: ResourceGrantUserDto;
|
||||||
targetUserId?: string;
|
targetUserId?: number | string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,14 +246,14 @@ export interface GrantResourcePayload {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
targetUserId: string;
|
targetUserId: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrantResourceGroupPayload {
|
export interface GrantResourceGroupPayload {
|
||||||
commandId: string;
|
commandId: string;
|
||||||
groupId: number;
|
groupId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
targetUserId: string;
|
targetUserId: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceGroupItemPayload {
|
export interface ResourceGroupItemPayload {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ const drawerZIndex = 1600;
|
|||||||
export function GiftSelectField({
|
export function GiftSelectField({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
drawerTitle = "选择礼物",
|
drawerTitle = "选择礼物",
|
||||||
fallbackGift = null,
|
|
||||||
gifts = [],
|
gifts = [],
|
||||||
label = "礼物",
|
label = "礼物",
|
||||||
loading = false,
|
loading = false,
|
||||||
@ -20,150 +19,11 @@ export function GiftSelectField({
|
|||||||
required = true,
|
required = true,
|
||||||
value,
|
value,
|
||||||
}) {
|
}) {
|
||||||
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
|
||||||
const displayValue = picker.selectedGift
|
|
||||||
? giftName(picker.selectedGift)
|
|
||||||
: picker.normalizedValue
|
|
||||||
? `礼物 #${picker.normalizedValue}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
className={styles.field}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
label={label}
|
|
||||||
placeholder={loading ? "礼物加载中" : placeholder}
|
|
||||||
required={required}
|
|
||||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
|
||||||
value={displayValue}
|
|
||||||
onClick={picker.openDrawer}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
picker.openDrawer();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GiftPreviewSelectField({
|
|
||||||
badge = "",
|
|
||||||
className = "",
|
|
||||||
disabled = false,
|
|
||||||
drawerTitle = "选择礼物",
|
|
||||||
fallbackGift = null,
|
|
||||||
gifts = [],
|
|
||||||
label = "礼物",
|
|
||||||
loading = false,
|
|
||||||
onChange = () => {},
|
|
||||||
placeholder = "请选择礼物",
|
|
||||||
required = true,
|
|
||||||
value,
|
|
||||||
}) {
|
|
||||||
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
|
||||||
const selectedGift = picker.selectedGift;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
aria-label={`${label} ${selectedGift ? giftName(selectedGift) : placeholder}`}
|
|
||||||
className={[styles.previewField, className].filter(Boolean).join(" ")}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
type="button"
|
|
||||||
onClick={picker.openDrawer}
|
|
||||||
>
|
|
||||||
<span className={styles.previewTop}>
|
|
||||||
<span className={styles.previewLabel}>
|
|
||||||
{label}
|
|
||||||
{required ? <span className={styles.previewRequired}>*</span> : null}
|
|
||||||
</span>
|
|
||||||
{badge ? <span className={styles.previewBadge}>{badge}</span> : null}
|
|
||||||
</span>
|
|
||||||
<span className={styles.previewBody}>
|
|
||||||
<GiftThumb className={styles.previewThumb} gift={selectedGift} />
|
|
||||||
<span className={styles.previewCopy}>
|
|
||||||
<span className={styles.previewName}>
|
|
||||||
{selectedGift ? giftName(selectedGift) : loading ? "礼物加载中" : placeholder}
|
|
||||||
</span>
|
|
||||||
<span className={styles.previewMeta}>
|
|
||||||
{selectedGift
|
|
||||||
? `${giftIdentity(selectedGift)} · 金币 ${formatNumber(giftCoinPrice(selectedGift))}`
|
|
||||||
: "点击选择礼物"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GiftSelectDrawerContent({ drawerTitle, picker }) {
|
|
||||||
return (
|
|
||||||
<SideDrawer
|
|
||||||
className={styles.drawer}
|
|
||||||
contentClassName={styles.drawerBody}
|
|
||||||
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
|
||||||
open={picker.open}
|
|
||||||
title={drawerTitle}
|
|
||||||
width="wide"
|
|
||||||
onClose={picker.closeDrawer}
|
|
||||||
>
|
|
||||||
<div className={styles.filters}>
|
|
||||||
<TextField
|
|
||||||
className={styles.search}
|
|
||||||
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchOutlined fontSize="small" />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
value={picker.query}
|
|
||||||
onChange={(event) => picker.setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{picker.filteredGifts.map((gift) => {
|
|
||||||
const selected = String(gift.giftId) === picker.normalizedValue;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
|
||||||
key={gift.giftId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => picker.selectGift(gift)}
|
|
||||||
>
|
|
||||||
<GiftThumb gift={gift} />
|
|
||||||
<span className={styles.name}>{giftName(gift)}</span>
|
|
||||||
<span className={styles.meta}>{giftIdentity(gift)}</span>
|
|
||||||
<span className={styles.tags}>
|
|
||||||
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
|
||||||
<span className={styles.tag}>金币 {formatNumber(giftCoinPrice(gift))}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!picker.filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
|
||||||
</div>
|
|
||||||
</SideDrawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useGiftPicker({ fallbackGift, gifts, loading, onChange, value }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedValue = String(value || "");
|
const normalizedValue = String(value || "");
|
||||||
const selectedGift =
|
const selectedGift = gifts.find((gift) => String(gift.giftId) === normalizedValue);
|
||||||
gifts.find((gift) => String(gift.giftId) === normalizedValue) ||
|
const displayValue = selectedGift ? giftName(selectedGift) : normalizedValue ? `礼物 #${normalizedValue}` : "";
|
||||||
(normalizedValue && fallbackGift && String(fallbackGift.giftId || "") === normalizedValue ? fallbackGift : null);
|
|
||||||
const filteredGifts = useMemo(() => {
|
const filteredGifts = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
@ -186,58 +46,101 @@ function useGiftPicker({ fallbackGift, gifts, loading, onChange, value }) {
|
|||||||
}, [gifts, query]);
|
}, [gifts, query]);
|
||||||
|
|
||||||
const openDrawer = () => {
|
const openDrawer = () => {
|
||||||
if (!loading) {
|
if (!disabled && !loading) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDrawer = () => setOpen(false);
|
|
||||||
|
|
||||||
const selectGift = (gift) => {
|
const selectGift = (gift) => {
|
||||||
onChange(String(gift.giftId), gift);
|
onChange(String(gift.giftId), gift);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return (
|
||||||
closeDrawer,
|
<>
|
||||||
filteredGifts,
|
<TextField
|
||||||
normalizedValue,
|
fullWidth
|
||||||
open,
|
className={styles.field}
|
||||||
openDrawer,
|
disabled={disabled || loading}
|
||||||
query,
|
label={label}
|
||||||
selectGift,
|
placeholder={loading ? "礼物加载中" : placeholder}
|
||||||
selectedGift,
|
required={required}
|
||||||
setQuery,
|
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.drawer}
|
||||||
|
contentClassName={styles.drawerBody}
|
||||||
|
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
||||||
|
open={open}
|
||||||
|
title={drawerTitle}
|
||||||
|
width="wide"
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<TextField
|
||||||
|
className={styles.search}
|
||||||
|
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchOutlined fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{filteredGifts.map((gift) => {
|
||||||
|
const selected = String(gift.giftId) === normalizedValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
||||||
|
key={gift.giftId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectGift(gift)}
|
||||||
|
>
|
||||||
|
<GiftThumb gift={gift} />
|
||||||
|
<span className={styles.name}>{giftName(gift)}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{gift.resource?.resourceCode || gift.giftId || `资源 ${gift.resourceId || "-"}`}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tags}>
|
||||||
|
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
||||||
|
<span className={styles.tag}>金币 {formatNumber(gift.coinPrice)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GiftThumb({ className = "", gift }) {
|
function GiftThumb({ gift }) {
|
||||||
const imageUrl = imageURL(
|
const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl);
|
||||||
gift?.resource?.previewUrl || gift?.resource?.assetUrl || gift?.resource?.animationUrl || gift?.giftIconUrl,
|
|
||||||
);
|
|
||||||
const [failedUrl, setFailedUrl] = useState("");
|
|
||||||
const displayImageUrl = imageUrl && imageUrl !== failedUrl ? imageUrl : "";
|
|
||||||
return (
|
return (
|
||||||
<span className={[styles.thumb, className].filter(Boolean).join(" ")}>
|
<span className={styles.thumb}>
|
||||||
{displayImageUrl ? (
|
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
|
||||||
<img alt="" src={displayImageUrl} onError={() => setFailedUrl(displayImageUrl)} />
|
|
||||||
) : (
|
|
||||||
<CardGiftcardOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function giftName(gift) {
|
function giftName(gift) {
|
||||||
return gift?.name || gift?.giftName || gift?.resource?.name || gift?.giftId || "礼物";
|
return gift?.name || gift?.resource?.name || gift?.giftId || "礼物";
|
||||||
}
|
|
||||||
|
|
||||||
function giftIdentity(gift) {
|
|
||||||
return gift?.resource?.resourceCode || gift?.giftId || `资源 ${gift?.resourceId || "-"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function giftCoinPrice(gift) {
|
|
||||||
return gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageURL(value) {
|
function imageURL(value) {
|
||||||
|
|||||||
@ -2,115 +2,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewField {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 88px;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewField:hover {
|
|
||||||
border-color: var(--primary-border);
|
|
||||||
background: var(--primary-surface);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewField:focus-visible {
|
|
||||||
outline: 3px solid var(--focus-ring);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewField:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.58;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewTop {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewLabel {
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewRequired {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
height: var(--admin-tag-height);
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
border-radius: var(--admin-tag-radius);
|
|
||||||
background: var(--primary-surface);
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewBody {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: 54px minmax(0, 1fr);
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewThumb {
|
|
||||||
width: 54px;
|
|
||||||
height: 54px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewCopy {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewName {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 750;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewMeta {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer.drawer {
|
.drawer.drawer {
|
||||||
width: min(720px, 100vw);
|
width: min(720px, 100vw);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -268,8 +159,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card,
|
.card {
|
||||||
.previewField {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -389,13 +389,13 @@ export const resourceGrantFormSchema = z
|
|||||||
targetUserId: z.union([z.string(), z.number()]),
|
targetUserId: z.union([z.string(), z.number()]),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const targetUserId = String(value.targetUserId || "").trim();
|
const targetUserId = Number(value.targetUserId);
|
||||||
const resourceIds = grantResourceIds(value);
|
const resourceIds = grantResourceIds(value);
|
||||||
const groupId = Number(value.groupId || 0);
|
const groupId = Number(value.groupId || 0);
|
||||||
const quantity = Number(value.quantity || 1);
|
const quantity = Number(value.quantity || 1);
|
||||||
const durationDays = Number(value.durationDays || 0);
|
const durationDays = Number(value.durationDays || 0);
|
||||||
|
|
||||||
if (!/^[1-9]\d*$/.test(targetUserId)) {
|
if (!Number.isInteger(targetUserId) || targetUserId <= 0) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请输入用户 ID",
|
message: "请输入用户 ID",
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export interface RoomTurnoverRewardConfigPayload {
|
|||||||
export interface RoomTurnoverRewardSettlementDto {
|
export interface RoomTurnoverRewardSettlementDto {
|
||||||
settlementId: string;
|
settlementId: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: number;
|
||||||
periodStartMs: number;
|
periodStartMs: number;
|
||||||
periodEndMs: number;
|
periodEndMs: number;
|
||||||
coinSpent: number;
|
coinSpent: number;
|
||||||
@ -140,7 +140,7 @@ function normalizeSettlement(item: RawSettlement): RoomTurnoverRewardSettlementD
|
|||||||
return {
|
return {
|
||||||
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||||
roomId: stringValue(item.roomId ?? item.room_id),
|
roomId: stringValue(item.roomId ?? item.room_id),
|
||||||
ownerUserId: stringValue(item.ownerUserId ?? item.owner_user_id),
|
ownerUserId: numberValue(item.ownerUserId ?? item.owner_user_id),
|
||||||
periodStartMs: numberValue(item.periodStartMs ?? item.period_start_ms),
|
periodStartMs: numberValue(item.periodStartMs ?? item.period_start_ms),
|
||||||
periodEndMs: numberValue(item.periodEndMs ?? item.period_end_ms),
|
periodEndMs: numberValue(item.periodEndMs ?? item.period_end_ms),
|
||||||
coinSpent: numberValue(item.coinSpent ?? item.coin_spent),
|
coinSpent: numberValue(item.coinSpent ?? item.coin_spent),
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export interface SevenDayCheckInConfigPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SevenDayCheckInClaimUserDto {
|
export interface SevenDayCheckInClaimUserDto {
|
||||||
userId?: string;
|
userId?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
@ -37,7 +37,7 @@ export interface SevenDayCheckInClaimUserDto {
|
|||||||
export interface SevenDayCheckInClaimDto {
|
export interface SevenDayCheckInClaimDto {
|
||||||
claimId: string;
|
claimId: string;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
userId: string;
|
userId: number;
|
||||||
user?: SevenDayCheckInClaimUserDto;
|
user?: SevenDayCheckInClaimUserDto;
|
||||||
cycleNo: number;
|
cycleNo: number;
|
||||||
checkinDay?: string;
|
checkinDay?: string;
|
||||||
@ -121,9 +121,9 @@ function normalizeClaim(item: RawClaim): SevenDayCheckInClaimDto {
|
|||||||
return {
|
return {
|
||||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||||
commandId: stringValue(item.commandId ?? item.command_id),
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
user: {
|
user: {
|
||||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export interface WeeklyStarCyclePayload {
|
|||||||
|
|
||||||
export interface WeeklyStarEntryDto {
|
export interface WeeklyStarEntryDto {
|
||||||
rankNo: number;
|
rankNo: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
score: number;
|
score: number;
|
||||||
firstScoredAtMs: number;
|
firstScoredAtMs: number;
|
||||||
lastScoredAtMs: number;
|
lastScoredAtMs: number;
|
||||||
@ -51,7 +51,7 @@ export interface WeeklyStarSettlementDto {
|
|||||||
settlementId: string;
|
settlementId: string;
|
||||||
cycleId: string;
|
cycleId: string;
|
||||||
rankNo: number;
|
rankNo: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
score: number;
|
score: number;
|
||||||
resourceGroupId: number;
|
resourceGroupId: number;
|
||||||
walletCommandId: string;
|
walletCommandId: string;
|
||||||
@ -161,7 +161,7 @@ function normalizeReward(item: Raw): WeeklyStarRewardDto {
|
|||||||
function normalizeEntry(item: Raw): WeeklyStarEntryDto {
|
function normalizeEntry(item: Raw): WeeklyStarEntryDto {
|
||||||
return {
|
return {
|
||||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
score: numberValue(item.score),
|
score: numberValue(item.score),
|
||||||
firstScoredAtMs: numberValue(item.firstScoredAtMs ?? item.first_scored_at_ms),
|
firstScoredAtMs: numberValue(item.firstScoredAtMs ?? item.first_scored_at_ms),
|
||||||
lastScoredAtMs: numberValue(item.lastScoredAtMs ?? item.last_scored_at_ms),
|
lastScoredAtMs: numberValue(item.lastScoredAtMs ?? item.last_scored_at_ms),
|
||||||
@ -173,7 +173,7 @@ function normalizeSettlement(item: Raw): WeeklyStarSettlementDto {
|
|||||||
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||||
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
score: numberValue(item.score),
|
score: numberValue(item.score),
|
||||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface ApiEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const API_OPERATIONS = {
|
export const API_OPERATIONS = {
|
||||||
|
adjustDicePool: "adjustDicePool",
|
||||||
appBanUser: "appBanUser",
|
appBanUser: "appBanUser",
|
||||||
appGetUser: "appGetUser",
|
appGetUser: "appGetUser",
|
||||||
appListBannedUsers: "appListBannedUsers",
|
appListBannedUsers: "appListBannedUsers",
|
||||||
@ -271,6 +272,13 @@ export const API_OPERATIONS = {
|
|||||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||||
|
|
||||||
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||||
|
adjustDicePool: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.adjustDicePool,
|
||||||
|
path: "/v1/admin/game/self-games/{game_id}/pool/adjust",
|
||||||
|
permission: "game:update",
|
||||||
|
permissions: ["game:update"],
|
||||||
|
},
|
||||||
appBanUser: {
|
appBanUser: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.appBanUser,
|
operationId: API_OPERATIONS.appBanUser,
|
||||||
|
|||||||
40
src/shared/api/generated/schema.d.ts
vendored
40
src/shared/api/generated/schema.d.ts
vendored
@ -1188,6 +1188,22 @@ export interface paths {
|
|||||||
patch: operations["updateDiceConfig"];
|
patch: operations["updateDiceConfig"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["adjustDicePool"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/game/robots": {
|
"/admin/game/robots": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3005,8 +3021,8 @@ export interface components {
|
|||||||
joinEnabled?: boolean;
|
joinEnabled?: boolean;
|
||||||
maxHosts?: number;
|
maxHosts?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
ownerUserId?: string;
|
ownerUserId?: number;
|
||||||
parentBdUserId?: string;
|
parentBdUserId?: number;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -3014,12 +3030,12 @@ export interface components {
|
|||||||
BDProfile: {
|
BDProfile: {
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
createdByUserId?: number;
|
createdByUserId?: number;
|
||||||
parentLeaderUserId?: string;
|
parentLeaderUserId?: number;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
role?: string;
|
role?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
};
|
};
|
||||||
CreateUserResult: {
|
CreateUserResult: {
|
||||||
initialPassword?: string;
|
initialPassword?: string;
|
||||||
@ -3041,7 +3057,7 @@ export interface components {
|
|||||||
source?: string;
|
source?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
};
|
};
|
||||||
Log: {
|
Log: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -4777,6 +4793,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
adjustDicePool: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
game_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listDiceRobots: {
|
listDiceRobots: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -228,15 +228,10 @@ export interface CoinSellerLedgerDto {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
operator?: CoinAdjustmentOperatorDto;
|
operator?: CoinAdjustmentOperatorDto;
|
||||||
operatorUserId?: string;
|
operatorUserId?: string;
|
||||||
paidAmountMicro?: number;
|
|
||||||
paidCurrencyCode?: string;
|
|
||||||
receiver: CoinLedgerUserDto;
|
receiver: CoinLedgerUserDto;
|
||||||
reason?: string;
|
|
||||||
seller: CoinLedgerUserDto;
|
seller: CoinLedgerUserDto;
|
||||||
sellerBalanceAfter: number;
|
sellerBalanceAfter: number;
|
||||||
stockType?: string;
|
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
transferUsdMinor?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoinAdjustmentOperatorDto {
|
export interface CoinAdjustmentOperatorDto {
|
||||||
@ -277,64 +272,6 @@ export interface CoinAdjustmentCreateDto {
|
|||||||
user: CoinLedgerUserDto;
|
user: CoinLedgerUserDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalaryWalletDto {
|
|
||||||
assetType: string;
|
|
||||||
availableAmount: number;
|
|
||||||
frozenAmount?: number;
|
|
||||||
role: string;
|
|
||||||
updatedAtMs?: number;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalaryWalletOperatorDto {
|
|
||||||
adminId?: string;
|
|
||||||
name?: string;
|
|
||||||
username?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalaryWalletHistoryDto {
|
|
||||||
amountMinor: number;
|
|
||||||
assetType: string;
|
|
||||||
availableAfter: number;
|
|
||||||
availableDelta: number;
|
|
||||||
bizType?: string;
|
|
||||||
commandId?: string;
|
|
||||||
counterpartyUserId?: string;
|
|
||||||
createdAtMs?: number;
|
|
||||||
direction: "income" | "expense" | string;
|
|
||||||
entryId: number;
|
|
||||||
externalRef?: string;
|
|
||||||
frozenAfter?: number;
|
|
||||||
frozenDelta?: number;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
operator?: SalaryWalletOperatorDto;
|
|
||||||
operatorUserId?: string;
|
|
||||||
reason?: string;
|
|
||||||
role: string;
|
|
||||||
roomId?: string;
|
|
||||||
transactionId: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalaryWalletAdjustPayload {
|
|
||||||
amountMinor: number;
|
|
||||||
commandId?: string;
|
|
||||||
reason: string;
|
|
||||||
role: "host" | "agency" | "bd" | "bd_leader" | string;
|
|
||||||
targetUserId: EntityId;
|
|
||||||
type: "increase" | "decrease";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalaryWalletAdjustResultDto {
|
|
||||||
amountMinor: number;
|
|
||||||
assetType: string;
|
|
||||||
balance: SalaryWalletDto;
|
|
||||||
reason?: string;
|
|
||||||
role: string;
|
|
||||||
transactionId: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReportUserDto {
|
export interface ReportUserDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
defaultDisplayUserId?: string;
|
defaultDisplayUserId?: string;
|
||||||
@ -387,13 +324,13 @@ export interface RechargeBillDto {
|
|||||||
policyVersion?: string;
|
policyVersion?: string;
|
||||||
rechargeType?: string;
|
rechargeType?: string;
|
||||||
sellerRegionId?: number;
|
sellerRegionId?: number;
|
||||||
sellerUserId?: string;
|
sellerUserId?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
targetRegionId?: number;
|
targetRegionId?: number;
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
usdMinorAmount: number;
|
usdMinorAmount: number;
|
||||||
user?: RechargeBillUserDto;
|
user?: RechargeBillUserDto;
|
||||||
userId: string;
|
userId: number;
|
||||||
seller?: RechargeBillUserDto;
|
seller?: RechargeBillUserDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -504,7 +441,7 @@ export interface BDProfileDto {
|
|||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
parentLeaderUserId?: string;
|
parentLeaderUserId?: number;
|
||||||
parentLeaderDisplayUserId?: string;
|
parentLeaderDisplayUserId?: string;
|
||||||
parentLeaderUsername?: string;
|
parentLeaderUsername?: string;
|
||||||
parentLeaderAvatar?: string;
|
parentLeaderAvatar?: string;
|
||||||
@ -512,11 +449,10 @@ export interface BDProfileDto {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
salaryWallet?: SalaryWalletDto;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
subBdCount?: number;
|
subBdCount?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,14 +466,13 @@ export interface AgencyDto {
|
|||||||
ownerAvatar?: string;
|
ownerAvatar?: string;
|
||||||
ownerDisplayUserId?: string;
|
ownerDisplayUserId?: string;
|
||||||
ownerUsername?: string;
|
ownerUsername?: string;
|
||||||
ownerUserId?: string;
|
ownerUserId?: number;
|
||||||
parentBdAvatar?: string;
|
parentBdAvatar?: string;
|
||||||
parentBdDisplayUserId?: string;
|
parentBdDisplayUserId?: string;
|
||||||
parentBdUsername?: string;
|
parentBdUsername?: string;
|
||||||
parentBdUserId?: string;
|
parentBdUserId?: number;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
salaryWallet?: SalaryWalletDto;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
@ -549,7 +484,7 @@ export interface HostProfileDto {
|
|||||||
currentAgencyName?: string;
|
currentAgencyName?: string;
|
||||||
currentAgencyOwnerAvatar?: string;
|
currentAgencyOwnerAvatar?: string;
|
||||||
currentAgencyOwnerDisplayUserId?: string;
|
currentAgencyOwnerDisplayUserId?: string;
|
||||||
currentAgencyOwnerUserId?: string;
|
currentAgencyOwnerUserId?: number;
|
||||||
currentAgencyOwnerUsername?: string;
|
currentAgencyOwnerUsername?: string;
|
||||||
currentMembershipId?: number;
|
currentMembershipId?: number;
|
||||||
diamond?: number;
|
diamond?: number;
|
||||||
@ -559,11 +494,10 @@ export interface HostProfileDto {
|
|||||||
firstBecameHostAtMs?: number;
|
firstBecameHostAtMs?: number;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
salaryWallet?: SalaryWalletDto;
|
|
||||||
source?: string;
|
source?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -582,7 +516,7 @@ export interface CoinSellerDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
totalRechargeUsdtMicro?: number;
|
totalRechargeUsdtMicro?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -606,21 +540,6 @@ export interface CoinSellerStockCreditDto {
|
|||||||
transactionId: string;
|
transactionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoinSellerStockDebitPayload {
|
|
||||||
coinAmount: number;
|
|
||||||
commandId: string;
|
|
||||||
evidenceRef?: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoinSellerStockDebitDto {
|
|
||||||
availableDelta: number;
|
|
||||||
balanceAfter: number;
|
|
||||||
coinAmount: number;
|
|
||||||
sellerUserId: string;
|
|
||||||
transactionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoinSellerSalaryRateTierDto {
|
export interface CoinSellerSalaryRateTierDto {
|
||||||
coinPerUsd: number;
|
coinPerUsd: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -1119,7 +1038,7 @@ export interface HostCommandPayload {
|
|||||||
|
|
||||||
export interface CreateBDLeaderPayload extends HostCommandPayload {
|
export interface CreateBDLeaderPayload extends HostCommandPayload {
|
||||||
positionAlias?: string;
|
positionAlias?: string;
|
||||||
targetUserId: string;
|
targetUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
|
export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
|
||||||
@ -1127,8 +1046,8 @@ export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBDPayload extends HostCommandPayload {
|
export interface CreateBDPayload extends HostCommandPayload {
|
||||||
parentLeaderUserId: string;
|
parentLeaderUserId: number;
|
||||||
targetUserId: string;
|
targetUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BDStatusPayload extends HostCommandPayload {
|
export interface BDStatusPayload extends HostCommandPayload {
|
||||||
@ -1137,7 +1056,7 @@ export interface BDStatusPayload extends HostCommandPayload {
|
|||||||
|
|
||||||
export interface CreateCoinSellerPayload extends HostCommandPayload {
|
export interface CreateCoinSellerPayload extends HostCommandPayload {
|
||||||
contact?: string;
|
contact?: string;
|
||||||
targetUserId: string;
|
targetUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoinSellerStatusPayload extends BDStatusPayload {
|
export interface CoinSellerStatusPayload extends BDStatusPayload {
|
||||||
@ -1147,8 +1066,8 @@ export interface CoinSellerStatusPayload extends BDStatusPayload {
|
|||||||
export interface CreateAgencyPayload extends HostCommandPayload {
|
export interface CreateAgencyPayload extends HostCommandPayload {
|
||||||
joinEnabled: boolean;
|
joinEnabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
ownerUserId: string;
|
ownerUserId: number;
|
||||||
parentBdUserId: string;
|
parentBdUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgencyJoinEnabledPayload extends HostCommandPayload {
|
export interface AgencyJoinEnabledPayload extends HostCommandPayload {
|
||||||
|
|||||||
@ -230,7 +230,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
margin: 12px !important;
|
|
||||||
width: calc(100vw - 24px) !important;
|
width: calc(100vw - 24px) !important;
|
||||||
max-width: calc(100vw - 24px) !important;
|
max-width: calc(100vw - 24px) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import MenuList from "@mui/material/MenuList";
|
|||||||
import Popover from "@mui/material/Popover";
|
import Popover from "@mui/material/Popover";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
@ -30,7 +30,6 @@ export function DataTable({
|
|||||||
minWidth = "980px",
|
minWidth = "980px",
|
||||||
onColumnWidthsChange,
|
onColumnWidthsChange,
|
||||||
pagination,
|
pagination,
|
||||||
renderRowDetail,
|
|
||||||
resizableColumns = true,
|
resizableColumns = true,
|
||||||
rowKey,
|
rowKey,
|
||||||
tableClassName = "",
|
tableClassName = "",
|
||||||
@ -80,9 +79,6 @@ export function DataTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
|
const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
|
||||||
const tableContentWidth = `${Math.ceil(
|
|
||||||
preparedColumns.reduce((total, column) => total + readMinWidth(column.width), 0)
|
|
||||||
)}px`;
|
|
||||||
const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
|
const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
|
||||||
const activeFilter = activeFilterColumn?.filter || null;
|
const activeFilter = activeFilterColumn?.filter || null;
|
||||||
const activeFilterIsText = activeFilter?.type === "text";
|
const activeFilterIsText = activeFilter?.type === "text";
|
||||||
@ -270,11 +266,7 @@ export function DataTable({
|
|||||||
<div className="table-scroll" ref={scrollRef}>
|
<div className="table-scroll" ref={scrollRef}>
|
||||||
<div
|
<div
|
||||||
className={["admin-table", resizeSession ? "admin-table--resizing" : "", tableClassName].filter(Boolean).join(" ")}
|
className={["admin-table", resizeSession ? "admin-table--resizing" : "", tableClassName].filter(Boolean).join(" ")}
|
||||||
style={{
|
style={{ "--admin-table-columns": gridTemplate, "--admin-table-min-width": minWidth }}
|
||||||
"--admin-table-columns": gridTemplate,
|
|
||||||
"--admin-table-content-width": tableContentWidth,
|
|
||||||
"--admin-table-min-width": minWidth
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="admin-row admin-row--head">
|
<div className="admin-row admin-row--head">
|
||||||
{preparedColumns.map((column, index) => {
|
{preparedColumns.map((column, index) => {
|
||||||
@ -312,19 +304,15 @@ export function DataTable({
|
|||||||
{safeItems.map((item, index) => {
|
{safeItems.map((item, index) => {
|
||||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||||
const key = rowKey(item);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} {...restRowProps}>
|
{preparedColumns.map((column) => (
|
||||||
{preparedColumns.map((column) => (
|
<div className={cellClassName(column)} key={column.key}>
|
||||||
<div className={cellClassName(column)} key={column.key}>
|
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
{renderRowDetail ? renderRowDetail(item, index, renderContext) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
||||||
|
|||||||
@ -61,22 +61,6 @@ test("only shows the table filter reset action when filters are active", () => {
|
|||||||
expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("expands table background to the minimum width required by columns", () => {
|
|
||||||
const columns = [
|
|
||||||
{ key: "user", label: "用户", width: "minmax(220px, 1fr)" },
|
|
||||||
{ key: "createdAt", label: "创建时间", width: "minmax(180px, 1fr)" },
|
|
||||||
{ key: "actions", label: "操作" }
|
|
||||||
];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TimeZoneProvider>
|
|
||||||
<DataTable columns={columns} items={[{ createdAt: "2026-06-15", id: 1, user: "admin" }]} minWidth="300px" rowKey={(item) => item.id} />
|
|
||||||
</TimeZoneProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(document.querySelector(".admin-table")?.style.getPropertyValue("--admin-table-content-width")).toBe("512px");
|
|
||||||
});
|
|
||||||
|
|
||||||
function TimezoneTableFixture() {
|
function TimezoneTableFixture() {
|
||||||
const { setTimeZone } = useTimeZone();
|
const { setTimeZone } = useTimeZone();
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import InputBase from "@mui/material/InputBase";
|
import InputBase from "@mui/material/InputBase";
|
||||||
import Search from "@mui/icons-material/Search";
|
import Search from "@mui/icons-material/Search";
|
||||||
|
|
||||||
export function SearchBox({ autoFocus = false, className = "", label = "搜索", onChange, placeholder, value }) {
|
export function SearchBox({ autoFocus = false, className = "", onChange, placeholder, value }) {
|
||||||
const hasValue = value !== undefined && value !== null && String(value).length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={["search-box", hasValue ? "search-box--active" : "", className].filter(Boolean).join(" ")}>
|
<label className={["search-box", className].filter(Boolean).join(" ")}>
|
||||||
<span className="search-box__label">
|
<Search fontSize="small" />
|
||||||
<Search fontSize="inherit" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
<InputBase
|
<InputBase
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
|
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
|
||||||
const selectedDate = dateForField(draft, activeField, viewDate);
|
const selectedDate = dateForField(draft, activeField, viewDate);
|
||||||
const hasRange = Boolean(range.startMs || range.endMs);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -111,17 +110,14 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className={[styles.trigger, className].filter(Boolean).join(" ")}
|
className={[styles.trigger, className].filter(Boolean).join(" ")}
|
||||||
|
endIcon={<KeyboardArrowDownOutlined fontSize="small" />}
|
||||||
|
startIcon={<AccessTimeOutlined fontSize="small" />}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={openPopover}
|
onClick={openPopover}
|
||||||
>
|
>
|
||||||
<span className={styles.triggerMeta}>
|
<span className={styles.triggerText}>{displayLabel}</span>
|
||||||
<AccessTimeOutlined fontSize="inherit" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
<span className={styles.triggerText}>{hasRange ? displayLabel : "不限"}</span>
|
|
||||||
<KeyboardArrowDownOutlined className={styles.triggerChevron} fontSize="small" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Popover
|
<Popover
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
.trigger {
|
.trigger {
|
||||||
position: relative;
|
|
||||||
display: inline-grid !important;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 18px;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: end !important;
|
justify-content: flex-start !important;
|
||||||
justify-content: stretch !important;
|
|
||||||
gap: 0 !important;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 132px !important;
|
min-width: 132px !important;
|
||||||
max-width: min(420px, 100%);
|
max-width: min(420px, 100%);
|
||||||
padding: 7px 28px 3px var(--control-padding-x) !important;
|
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
background: var(--bg-input-strong) !important;
|
background: var(--bg-input-strong) !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
@ -27,50 +21,24 @@
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerMeta {
|
.trigger :global(.MuiButton-startIcon) {
|
||||||
position: absolute;
|
margin-right: var(--space-2);
|
||||||
top: -7px;
|
margin-left: 0;
|
||||||
left: var(--space-2);
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: calc(100% - var(--space-4));
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0 var(--space-1);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 14px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trigger:hover .triggerMeta,
|
.trigger :global(.MuiButton-endIcon) {
|
||||||
.trigger:focus-visible .triggerMeta {
|
margin-right: 0;
|
||||||
color: var(--primary);
|
margin-left: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerText {
|
.triggerText {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--control-font-size);
|
|
||||||
line-height: var(--control-line-height);
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerChevron {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: var(--space-2);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import Select from "@mui/material/Select";
|
||||||
|
|
||||||
const ranges = ["近 1 小时", "近 24 小时", "近 7 天"];
|
const ranges = ["近 1 小时", "近 24 小时", "近 7 天"];
|
||||||
|
|
||||||
export function TimeRangeSelect({ className = "", label = "时间", onChange, value }) {
|
export function TimeRangeSelect({ className = "", onChange, value }) {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<Select
|
||||||
className={["time-select", className].filter(Boolean).join(" ")}
|
className={["time-select", className].filter(Boolean).join(" ")}
|
||||||
label={label}
|
|
||||||
select
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ width: 116, height: 36, fontSize: "var(--admin-font-size)" }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
{ranges.map((range) => (
|
{ranges.map((range) => (
|
||||||
@ -19,6 +18,6 @@ export function TimeRangeSelect({ className = "", label = "时间", onChange, va
|
|||||||
{range}
|
{range}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export function ToastProvider({ children }) {
|
|||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
const showToast = useCallback((message, severity = "info") => {
|
const showToast = useCallback((message, severity = "info") => {
|
||||||
setToast(normalizeToast(message, severity));
|
setToast({ message, severity });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||||
@ -39,16 +39,3 @@ export function useToast() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToast(message, severity) {
|
|
||||||
if (message && typeof message === "object") {
|
|
||||||
return {
|
|
||||||
message: String(message.message || "操作失败"),
|
|
||||||
severity: message.severity || severity || "info"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
message: String(message || "操作失败"),
|
|
||||||
severity: severity || "info"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { expect, test } from "vitest";
|
|
||||||
import { ToastProvider, useToast } from "./ToastProvider.jsx";
|
|
||||||
|
|
||||||
function ToastTrigger({ payload, severity }) {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => showToast(payload, severity)}>
|
|
||||||
show
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("renders object-style toast payloads as text", () => {
|
|
||||||
render(
|
|
||||||
<ToastProvider>
|
|
||||||
<ToastTrigger payload={{ message: "工资结算失败", severity: "error" }} />
|
|
||||||
</ToastProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "show" }));
|
|
||||||
|
|
||||||
expect(screen.getByText("工资结算失败")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps tuple-style toast payloads working", () => {
|
|
||||||
render(
|
|
||||||
<ToastProvider>
|
|
||||||
<ToastTrigger payload="区域已更新" severity="success" />
|
|
||||||
</ToastProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "show" }));
|
|
||||||
|
|
||||||
expect(screen.getByText("区域已更新")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
@ -165,11 +165,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-switch {
|
.app-switch {
|
||||||
flex: 0 0 164px;
|
flex: 0 0 156px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timezone-switch {
|
.timezone-switch {
|
||||||
flex: 0 0 140px;
|
flex: 0 0 124px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-switch .MuiOutlinedInput-root,
|
.app-switch .MuiOutlinedInput-root,
|
||||||
@ -202,15 +202,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 420px;
|
flex: 1 1 420px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 0;
|
gap: var(--space-3);
|
||||||
padding: 7px var(--control-padding-x) 3px;
|
padding: 0 var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
@ -223,58 +222,31 @@
|
|||||||
transform var(--motion-base) var(--ease-emphasized);
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box__label {
|
|
||||||
position: absolute;
|
|
||||||
top: -7px;
|
|
||||||
left: var(--space-2);
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: calc(100% - var(--space-4));
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0 var(--space-1);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 14px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box:focus-within {
|
.search-box:focus-within {
|
||||||
border-color: var(--primary-border-active);
|
border-color: var(--primary-border-active);
|
||||||
background: var(--bg-input-strong);
|
background: var(--bg-input-strong);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box__label svg {
|
.search-box svg {
|
||||||
flex: 0 0 auto;
|
|
||||||
transition:
|
transition:
|
||||||
color var(--motion-base) var(--ease-standard),
|
color var(--motion-base) var(--ease-standard),
|
||||||
transform var(--motion-base) var(--ease-emphasized);
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box:focus-within .search-box__label,
|
.search-box:focus-within svg {
|
||||||
.search-box--active .search-box__label {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box:focus-within .search-box__label svg {
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger {
|
.search-trigger {
|
||||||
position: relative;
|
display: flex;
|
||||||
display: grid;
|
|
||||||
width: min(360px, 34vw);
|
width: min(360px, 34vw);
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
align-items: end;
|
align-items: center;
|
||||||
gap: 0;
|
gap: var(--space-3);
|
||||||
padding: 7px var(--control-padding-x) 3px;
|
padding: 0 var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
@ -290,36 +262,8 @@
|
|||||||
transform var(--motion-base) var(--ease-emphasized);
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger__label {
|
.search-trigger span {
|
||||||
position: absolute;
|
|
||||||
top: -7px;
|
|
||||||
left: var(--space-2);
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: calc(100% - var(--space-4));
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 var(--space-1);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 14px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-trigger__label svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-trigger__value {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--control-font-size);
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: var(--control-line-height);
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -332,22 +276,16 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-trigger:hover .search-trigger__label,
|
|
||||||
.search-trigger:focus-visible .search-trigger__label {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input {
|
.search-box input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--control-line-height);
|
height: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--control-font-size);
|
font-size: var(--admin-font-size);
|
||||||
font-weight: 650;
|
line-height: 20px;
|
||||||
line-height: var(--control-line-height);
|
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,10 +293,15 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-select.MuiTextField-root,
|
.time-select {
|
||||||
.time-select.MuiFormControl-root {
|
width: 116px;
|
||||||
flex: 0 0 124px;
|
height: var(--control-height);
|
||||||
width: 124px;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-input);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
|
|||||||
@ -108,10 +108,6 @@
|
|||||||
--admin-control-height: var(--control-height);
|
--admin-control-height: var(--control-height);
|
||||||
--admin-control-font-size: var(--control-font-size);
|
--admin-control-font-size: var(--control-font-size);
|
||||||
--admin-control-line-height: var(--control-line-height);
|
--admin-control-line-height: var(--control-line-height);
|
||||||
--admin-floating-label-font-size: 11px;
|
|
||||||
--admin-floating-label-line-height: 14px;
|
|
||||||
--admin-floating-label-offset-x: 9px;
|
|
||||||
--admin-floating-label-offset-y: -7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-select.MuiTextField-root,
|
.region-select.MuiTextField-root,
|
||||||
@ -223,19 +219,12 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-control-font-size);
|
font-size: var(--admin-control-font-size);
|
||||||
line-height: var(--admin-control-line-height);
|
line-height: var(--admin-control-line-height);
|
||||||
letter-spacing: 0;
|
transform: translate(12px, 6px) scale(1);
|
||||||
transform: translate(var(--control-padding-x), calc((var(--admin-control-height) - var(--admin-control-line-height)) / 2)) scale(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
|
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
|
||||||
.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink {
|
.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink {
|
||||||
max-width: calc((100% - 18px) / 0.78);
|
transform: translate(12px, -7px) scale(0.75);
|
||||||
padding: 0 var(--space-1);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
font-size: var(--admin-floating-label-font-size);
|
|
||||||
line-height: var(--admin-floating-label-line-height);
|
|
||||||
transform: translate(var(--admin-floating-label-offset-x), var(--admin-floating-label-offset-y)) scale(0.78);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
|
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
|
||||||
@ -1546,9 +1535,9 @@
|
|||||||
--admin-table-cell-x: var(--space-3);
|
--admin-table-cell-x: var(--space-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: max(100%, var(--admin-table-min-width, 900px), var(--admin-table-content-width, 0px));
|
width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
min-width: max(100%, var(--admin-table-min-width, 900px), var(--admin-table-content-width, 0px));
|
min-width: max(100%, var(--admin-table-min-width, 900px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row {
|
.admin-row {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user