活动相关配置
This commit is contained in:
parent
945e2e7047
commit
fd80e18aff
@ -270,6 +270,122 @@
|
|||||||
"x-permissions": ["first-recharge-reward:view"]
|
"x-permissions": ["first-recharge-reward:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/activity/cumulative-recharge-reward/grants": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listCumulativeRechargeRewardGrants",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cumulative-recharge-reward:view",
|
||||||
|
"x-permissions": ["cumulative-recharge-reward:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/room-turnover-reward/settlements": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listRoomTurnoverRewardSettlements",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-turnover-reward:view",
|
||||||
|
"x-permissions": ["room-turnover-reward:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/room-turnover-reward/settlements/{settlement_id}/retry": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "retryRoomTurnoverRewardSettlement",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-turnover-reward:retry",
|
||||||
|
"x-permissions": ["room-turnover-reward:retry"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/weekly-star/cycles": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listWeeklyStarCycles",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:view",
|
||||||
|
"x-permissions": ["weekly-star:view"]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createWeeklyStarCycle",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:create",
|
||||||
|
"x-permissions": ["weekly-star:create"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getWeeklyStarCycle",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:view",
|
||||||
|
"x-permissions": ["weekly-star:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateWeeklyStarCycle",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:update",
|
||||||
|
"x-permissions": ["weekly-star:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/status": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "setWeeklyStarCycleStatus",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:update",
|
||||||
|
"x-permissions": ["weekly-star:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/leaderboard": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listWeeklyStarLeaderboard",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:view",
|
||||||
|
"x-permissions": ["weekly-star:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/settlements": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listWeeklyStarSettlements",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "weekly-star:settle",
|
||||||
|
"x-permissions": ["weekly-star:settle"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/registration-reward/claims": {
|
"/admin/activity/registration-reward/claims": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRegistrationRewardClaims",
|
"operationId": "listRegistrationRewardClaims",
|
||||||
@ -278,6 +394,52 @@
|
|||||||
"$ref": "#/components/responses/EmptyResponse"
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "page_size",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "keyword",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "status",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "claimed_start_ms",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "claimed_end_ms",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"x-permission": "registration-reward:view",
|
"x-permission": "registration-reward:view",
|
||||||
"x-permissions": ["registration-reward:view"]
|
"x-permissions": ["registration-reward:view"]
|
||||||
}
|
}
|
||||||
@ -304,6 +466,50 @@
|
|||||||
"x-permissions": ["first-recharge-reward:update"]
|
"x-permissions": ["first-recharge-reward:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/activity/cumulative-recharge-reward/config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getCumulativeRechargeRewardConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cumulative-recharge-reward:view",
|
||||||
|
"x-permissions": ["cumulative-recharge-reward:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateCumulativeRechargeRewardConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cumulative-recharge-reward:update",
|
||||||
|
"x-permissions": ["cumulative-recharge-reward:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/room-turnover-reward/config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getRoomTurnoverRewardConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-turnover-reward:view",
|
||||||
|
"x-permissions": ["room-turnover-reward:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateRoomTurnoverRewardConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "room-turnover-reward:update",
|
||||||
|
"x-permissions": ["room-turnover-reward:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/registration-reward/config": {
|
"/admin/activity/registration-reward/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getRegistrationRewardConfig",
|
"operationId": "getRegistrationRewardConfig",
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
|||||||
import { MetricCard } from "./components/MetricCard.jsx";
|
import { MetricCard } from "./components/MetricCard.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { rangeEndMs, rangeStartMs, todayRange } from "./utils/time.js";
|
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||||
|
|
||||||
const initialFilterOptions = {
|
const initialFilterOptions = {
|
||||||
appOptions: [],
|
appOptions: [],
|
||||||
@ -17,7 +17,9 @@ const initialFilterOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function DatabiApp() {
|
export function DatabiApp() {
|
||||||
const initialRange = useMemo(() => todayRange(), []);
|
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
|
||||||
|
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
|
||||||
|
const [timeZone, setTimeZone] = useState(initialTimeZone);
|
||||||
const [range, setRange] = useState(initialRange);
|
const [range, setRange] = useState(initialRange);
|
||||||
const [countryId, setCountryId] = useState(0);
|
const [countryId, setCountryId] = useState(0);
|
||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
@ -29,6 +31,7 @@ export function DatabiApp() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
||||||
const overviewRequestIdRef = useRef(0);
|
const overviewRequestIdRef = useRef(0);
|
||||||
|
const overviewRef = 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]);
|
||||||
@ -42,6 +45,15 @@ export function DatabiApp() {
|
|||||||
setCountryId(0);
|
setCountryId(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTimeZoneChange = useCallback((value) => {
|
||||||
|
const nextTimeZone = writeStoredTimeZone(value);
|
||||||
|
setRange((currentRange) => {
|
||||||
|
const currentPreset = resolveRangePresetValue(currentRange, timeZone);
|
||||||
|
return currentPreset ? rangeForPreset(currentPreset, nextTimeZone) : currentRange;
|
||||||
|
});
|
||||||
|
setTimeZone(nextTimeZone);
|
||||||
|
}, [timeZone]);
|
||||||
|
|
||||||
const loadFilterOptions = useCallback(async () => {
|
const loadFilterOptions = useCallback(async () => {
|
||||||
setFiltersLoading(true);
|
setFiltersLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -60,15 +72,22 @@ export function DatabiApp() {
|
|||||||
}
|
}
|
||||||
}, [appCode]);
|
}, [appCode]);
|
||||||
|
|
||||||
const loadOverview = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
overviewRef.current = overview;
|
||||||
|
}, [overview]);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
||||||
const requestId = overviewRequestIdRef.current + 1;
|
const requestId = overviewRequestIdRef.current + 1;
|
||||||
overviewRequestIdRef.current = requestId;
|
overviewRequestIdRef.current = requestId;
|
||||||
|
const keepCurrentOverview = preserveData && overviewRef.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setOverview(null);
|
if (!keepCurrentOverview) {
|
||||||
|
setOverview(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const startMs = rangeStartMs(range);
|
const startMs = rangeStartMs(range, timeZone);
|
||||||
const endMs = rangeEndMs(range);
|
const endMs = rangeEndMs(range, timeZone);
|
||||||
const data = await fetchStatisticsOverview({
|
const data = await fetchStatisticsOverview({
|
||||||
appCode,
|
appCode,
|
||||||
countryId,
|
countryId,
|
||||||
@ -94,7 +113,9 @@ export function DatabiApp() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId === overviewRequestIdRef.current) {
|
if (requestId === overviewRequestIdRef.current) {
|
||||||
setOverview(null);
|
if (!keepCurrentOverview) {
|
||||||
|
setOverview(null);
|
||||||
|
}
|
||||||
setError(err.message || "请求失败");
|
setError(err.message || "请求失败");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -102,7 +123,7 @@ export function DatabiApp() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [appCode, countryId, range, regionId, scopedCountries]);
|
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadFilterOptions();
|
void loadFilterOptions();
|
||||||
@ -110,11 +131,15 @@ export function DatabiApp() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
const timer = window.setInterval(() => void loadOverview(), 60_000);
|
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadOverview]);
|
}, [loadOverview]);
|
||||||
|
|
||||||
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range }), [appCode, countryId, overview, range]);
|
const handleRefresh = useCallback(() => {
|
||||||
|
void loadOverview({ preserveData: true });
|
||||||
|
}, [loadOverview]);
|
||||||
|
|
||||||
|
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
||||||
const showSkeleton = loading && !overview;
|
const showSkeleton = loading && !overview;
|
||||||
const openLuckyGiftPools = useCallback(() => {
|
const openLuckyGiftPools = useCallback(() => {
|
||||||
setPoolModalOpen(true);
|
setPoolModalOpen(true);
|
||||||
@ -133,12 +158,15 @@ export function DatabiApp() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onAppChange={handleAppChange}
|
onAppChange={handleAppChange}
|
||||||
onCountryChange={setCountryId}
|
onCountryChange={setCountryId}
|
||||||
onRefresh={loadOverview}
|
onRefresh={handleRefresh}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
onRegionChange={handleRegionChange}
|
onRegionChange={handleRegionChange}
|
||||||
|
onTimeZoneChange={handleTimeZoneChange}
|
||||||
range={range}
|
range={range}
|
||||||
regionId={regionId}
|
regionId={regionId}
|
||||||
regionOptions={filterOptions.regionOptions}
|
regionOptions={filterOptions.regionOptions}
|
||||||
|
timeZone={timeZone}
|
||||||
|
timeZoneOptions={TIME_ZONE_OPTIONS}
|
||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -184,6 +212,34 @@ function hasCountryBreakdown(data) {
|
|||||||
return Array.isArray(data?.country_breakdown) && data.country_breakdown.length > 0;
|
return Array.isArray(data?.country_breakdown) && data.country_breakdown.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRangePresetValue(range, timeZone) {
|
||||||
|
const presets = [
|
||||||
|
{ range: todayRange(timeZone), value: "today" },
|
||||||
|
{ range: lastDaysRange(7, timeZone), value: "7d" },
|
||||||
|
{ range: lastDaysRange(14, timeZone), value: "14d" },
|
||||||
|
{ range: lastDaysRange(30, timeZone), value: "30d" },
|
||||||
|
{ range: thisMonthRange(timeZone), value: "month" }
|
||||||
|
];
|
||||||
|
return presets.find((preset) => sameRange(preset.range, range))?.value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeForPreset(value, timeZone) {
|
||||||
|
switch (value) {
|
||||||
|
case "today":
|
||||||
|
return todayRange(timeZone);
|
||||||
|
case "7d":
|
||||||
|
return lastDaysRange(7, timeZone);
|
||||||
|
case "14d":
|
||||||
|
return lastDaysRange(14, timeZone);
|
||||||
|
case "30d":
|
||||||
|
return lastDaysRange(30, timeZone);
|
||||||
|
case "month":
|
||||||
|
return thisMonthRange(timeZone);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function countryBelongsToRegion(country, regionId) {
|
function countryBelongsToRegion(country, regionId) {
|
||||||
if (!regionId || regionId === "all") {
|
if (!regionId || regionId === "all") {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
62
databi/src/DatabiApp.test.jsx
Normal file
62
databi/src/DatabiApp.test.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
|
import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview } from "./api.js";
|
||||||
|
|
||||||
|
vi.mock("./api.js", () => ({
|
||||||
|
fetchCountryStatisticsBreakdown: vi.fn(),
|
||||||
|
fetchFilterOptions: vi.fn(),
|
||||||
|
fetchStatisticsOverview: vi.fn(),
|
||||||
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
|
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./charts/EChart.jsx", () => ({
|
||||||
|
EChart: () => <div data-testid="databi-chart" />
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
|
||||||
|
fetchFilterOptions.mockResolvedValue({
|
||||||
|
appOptions: [{ code: "lalu", label: "Lalu", value: "lalu" }],
|
||||||
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||||
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
|
});
|
||||||
|
fetchCountryStatisticsBreakdown.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps current data visible during scheduled refresh", async () => {
|
||||||
|
const overview = { active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 };
|
||||||
|
fetchStatisticsOverview
|
||||||
|
.mockResolvedValueOnce(overview)
|
||||||
|
.mockResolvedValueOnce(overview)
|
||||||
|
.mockImplementationOnce(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
render(<DatabiApp />);
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
expect(screen.getByText("$1")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
|
||||||
|
expect(screen.getByText("$1")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function flushEffects() {
|
||||||
|
await act(async () => {
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,16 +2,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import { lastDaysRange, todayRange } from "../utils/time.js";
|
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
|
||||||
|
|
||||||
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
|
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
|
||||||
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
|
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
|
||||||
const rangeOptions = [
|
const rangeOptions = [
|
||||||
{ label: "今日", value: "today", getRange: () => todayRange() },
|
{ label: "今日", value: "today", getRange: (timeZone) => todayRange(timeZone) },
|
||||||
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
|
{ label: "近 7 日", value: "7d", getRange: (timeZone) => lastDaysRange(7, timeZone) },
|
||||||
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
|
{ label: "近 14 日", value: "14d", getRange: (timeZone) => lastDaysRange(14, timeZone) },
|
||||||
{ label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
|
{ label: "近 30 日", value: "30d", getRange: (timeZone) => lastDaysRange(30, timeZone) },
|
||||||
{ label: "本月", value: "month", getRange: thisMonthRange }
|
{ label: "本月", value: "month", getRange: (timeZone) => thisMonthRange(timeZone) }
|
||||||
];
|
];
|
||||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||||
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
|
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
|
||||||
@ -28,14 +28,18 @@ export function DatabiHeader({
|
|||||||
onCountryChange,
|
onCountryChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
onRegionChange,
|
onRegionChange,
|
||||||
|
onTimeZoneChange,
|
||||||
range,
|
range,
|
||||||
regionId,
|
regionId,
|
||||||
regionOptions = defaultRegionOptions
|
regionOptions = defaultRegionOptions,
|
||||||
|
timeZone,
|
||||||
|
timeZoneOptions = TIME_ZONE_OPTIONS
|
||||||
}) {
|
}) {
|
||||||
const [openControl, setOpenControl] = useState("");
|
const [openControl, setOpenControl] = useState("");
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
|
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
|
||||||
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
|
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
|
||||||
|
const timeZoneLabel = getTimeZoneLabel(timeZone);
|
||||||
const filteredCountries = useMemo(() => {
|
const filteredCountries = useMemo(() => {
|
||||||
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
|
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
|
||||||
return countryOptions;
|
return countryOptions;
|
||||||
@ -44,7 +48,7 @@ export function DatabiHeader({
|
|||||||
}, [countryOptions, regionId, regionOptions]);
|
}, [countryOptions, regionId, regionOptions]);
|
||||||
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
|
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
|
||||||
const activeRange = rangeOptions.find((item) => {
|
const activeRange = rangeOptions.find((item) => {
|
||||||
const next = item.getRange();
|
const next = item.getRange(timeZone);
|
||||||
return sameRange(next, range);
|
return sameRange(next, range);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,6 +81,18 @@ export function DatabiHeader({
|
|||||||
</div>
|
</div>
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
|
className="filter-control--timezone"
|
||||||
|
icon={<span className="control-glyph">TZ</span>}
|
||||||
|
isOpen={openControl === "timezone"}
|
||||||
|
label="时区"
|
||||||
|
onSelect={(value) => selectItem(onTimeZoneChange, value)}
|
||||||
|
onToggle={() => toggleControl("timezone")}
|
||||||
|
options={timeZoneOptions}
|
||||||
|
value={timeZone}
|
||||||
|
valueLabel={timeZoneLabel}
|
||||||
|
/>
|
||||||
|
<FilterMenu
|
||||||
|
className="filter-control--app"
|
||||||
icon={<span className="control-glyph">APP</span>}
|
icon={<span className="control-glyph">APP</span>}
|
||||||
isOpen={openControl === "app"}
|
isOpen={openControl === "app"}
|
||||||
label="App"
|
label="App"
|
||||||
@ -93,6 +109,7 @@ export function DatabiHeader({
|
|||||||
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
|
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
|
||||||
onToggle={() => toggleControl("range")}
|
onToggle={() => toggleControl("range")}
|
||||||
range={range}
|
range={range}
|
||||||
|
timeZone={timeZone}
|
||||||
/>
|
/>
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
icon={<PublicOutlined fontSize="small" />}
|
icon={<PublicOutlined fontSize="small" />}
|
||||||
@ -122,9 +139,9 @@ export function DatabiHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
|
function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
|
||||||
return (
|
return (
|
||||||
<div className="filter-control">
|
<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">
|
||||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||||
<span className="filter-copy">
|
<span className="filter-copy">
|
||||||
@ -200,8 +217,8 @@ function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range }) {
|
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone }) {
|
||||||
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange() }));
|
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange(timeZone) }));
|
||||||
const activeOption = options.find((item) => item.value === activeValue);
|
const activeOption = options.find((item) => item.value === activeValue);
|
||||||
const [draft, setDraft] = useState(() => normalizeRange(range));
|
const [draft, setDraft] = useState(() => normalizeRange(range));
|
||||||
const [activeField, setActiveField] = useState("start");
|
const [activeField, setActiveField] = useState("start");
|
||||||
@ -254,7 +271,7 @@ function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range }) {
|
|||||||
|
|
||||||
const applyDraft = () => {
|
const applyDraft = () => {
|
||||||
const normalized = normalizeRange(draft);
|
const normalized = normalizeRange(draft);
|
||||||
if (rangeToMs(normalized, "start") > rangeToMs(normalized, "end")) {
|
if (rangeStartMs(normalized, timeZone) > rangeEndMs(normalized, timeZone)) {
|
||||||
setError("结束时间必须晚于开始时间");
|
setError("结束时间必须晚于开始时间");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -356,12 +373,6 @@ function TimeColumn({ label, onSelect, options, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function thisMonthRange() {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date(end.getFullYear(), end.getMonth(), 1);
|
|
||||||
return { end: dateValue(end), endTime: "23:59:59", start: dateValue(start), startTime: "00:00:00" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateValue(date) {
|
function dateValue(date) {
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||||
}
|
}
|
||||||
@ -374,10 +385,6 @@ function formatDateTimePart(date, time) {
|
|||||||
return `${date} ${normalizeTime(time)}`;
|
return `${date} ${normalizeTime(time)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameRange(left, right) {
|
|
||||||
return left.start === right.start && left.end === right.end && normalizeTime(left.startTime) === normalizeTime(right.startTime) && normalizeTime(left.endTime) === normalizeTime(right.endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRange(range) {
|
function normalizeRange(range) {
|
||||||
return {
|
return {
|
||||||
end: range?.end || dateValue(new Date()),
|
end: range?.end || dateValue(new Date()),
|
||||||
@ -423,10 +430,6 @@ function normalizeTime(value) {
|
|||||||
return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`;
|
return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangeToMs(range, field) {
|
|
||||||
return new Date(`${range[field]}T${normalizeTime(range[`${field}Time`])}`).getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(value) {
|
function pad2(value) {
|
||||||
return String(value).padStart(2, "0");
|
return String(value).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlus
|
|||||||
import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js";
|
import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js";
|
||||||
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||||||
import { numberValue, ratio } from "../utils/number.js";
|
import { numberValue, ratio } from "../utils/number.js";
|
||||||
import { formatDateTime } from "../utils/time.js";
|
import { formatDateTime, sameRange, todayRange } from "../utils/time.js";
|
||||||
|
|
||||||
export function createDashboardModel(overview, { appCode, countryId, range }) {
|
export function createDashboardModel(overview, { appCode, countryId, range, timeZone }) {
|
||||||
const source = overview || {};
|
const source = overview || {};
|
||||||
const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日";
|
const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日";
|
||||||
const recharge = effectiveRechargeUSDMinor(source);
|
const recharge = effectiveRechargeUSDMinor(source);
|
||||||
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||||||
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
||||||
@ -83,7 +83,7 @@ export function createDashboardModel(overview, { appCode, countryId, range }) {
|
|||||||
{ 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),
|
||||||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
|
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,17 +130,8 @@ function formatDelta(value, unit) {
|
|||||||
return `${prefix}${normalized.toFixed(2)}${unit}`;
|
return `${prefix}${normalized.toFixed(2)}${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTodayRange(range) {
|
function isTodayRange(range, timeZone) {
|
||||||
const today = dateValue(new Date());
|
return sameRange(range, todayRange(timeZone));
|
||||||
return range?.start === today && range?.end === today;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateValue(date) {
|
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(value) {
|
|
||||||
return String(value).padStart(2, "0");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRevenueSeries(source) {
|
function normalizeRevenueSeries(source) {
|
||||||
|
|||||||
@ -84,7 +84,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-control {
|
.filter-control {
|
||||||
@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
.filter-trigger {
|
.filter-trigger {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 150px;
|
min-width: 138px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -109,6 +109,20 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-control--timezone .filter-trigger {
|
||||||
|
width: 118px;
|
||||||
|
min-width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control--app .filter-trigger {
|
||||||
|
width: 138px;
|
||||||
|
min-width: 138px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control--country .filter-trigger {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-trigger:hover,
|
.filter-trigger:hover,
|
||||||
.filter-trigger[aria-expanded="true"] {
|
.filter-trigger[aria-expanded="true"] {
|
||||||
border-color: rgba(39, 228, 245, 0.78);
|
border-color: rgba(39, 228, 245, 0.78);
|
||||||
@ -164,7 +178,7 @@
|
|||||||
|
|
||||||
.date-range-trigger {
|
.date-range-trigger {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 430px;
|
width: 336px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -226,7 +240,7 @@
|
|||||||
.date-range-values b {
|
.date-range-values b {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #eaf6ff;
|
color: #eaf6ff;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@ -1,13 +1,54 @@
|
|||||||
export function todayRange() {
|
export const DEFAULT_TIME_ZONE = "Asia/Shanghai";
|
||||||
const today = new Date();
|
export const TIME_ZONE_STORAGE_KEY = "hyapp-admin.display-time-zone";
|
||||||
return { end: dateInputValue(today), endTime: "23:59:59", start: dateInputValue(today), startTime: "00:00:00" };
|
export const TIME_ZONE_OPTIONS = [
|
||||||
|
{ label: "北京时间", value: "Asia/Shanghai" },
|
||||||
|
{ label: "UTC", value: "UTC" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dateFormatterCache = new Map();
|
||||||
|
const dateTimeFormatterCache = new Map();
|
||||||
|
|
||||||
|
export function todayRange(timeZone = DEFAULT_TIME_ZONE) {
|
||||||
|
const today = dateInputValueInTimeZone(new Date(), timeZone);
|
||||||
|
return { end: today, endTime: "23:59:59", start: today, startTime: "00:00:00" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastDaysRange(days) {
|
export function lastDaysRange(days, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
const end = new Date();
|
const end = dateInputValueInTimeZone(new Date(), timeZone);
|
||||||
const start = new Date(end);
|
const start = shiftDateValue(end, 1 - Number(days || 1));
|
||||||
start.setDate(end.getDate() - days + 1);
|
return { end, endTime: "23:59:59", start, startTime: "00:00:00" };
|
||||||
return { end: dateInputValue(end), endTime: "23:59:59", start: dateInputValue(start), startTime: "00:00:00" };
|
}
|
||||||
|
|
||||||
|
export function thisMonthRange(timeZone = DEFAULT_TIME_ZONE) {
|
||||||
|
const end = dateInputValueInTimeZone(new Date(), timeZone);
|
||||||
|
const [year, month] = end.split("-");
|
||||||
|
return { end, endTime: "23:59:59", start: `${year}-${month}-01`, startTime: "00:00:00" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTimeZone(value) {
|
||||||
|
return TIME_ZONE_OPTIONS.some((option) => option.value === value) ? value : DEFAULT_TIME_ZONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimeZoneLabel(value) {
|
||||||
|
return TIME_ZONE_OPTIONS.find((option) => option.value === value)?.label || "北京时间";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredTimeZone(storage = globalThis.localStorage) {
|
||||||
|
try {
|
||||||
|
return normalizeTimeZone(storage?.getItem(TIME_ZONE_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TIME_ZONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredTimeZone(value, storage = globalThis.localStorage) {
|
||||||
|
const normalized = normalizeTimeZone(value);
|
||||||
|
try {
|
||||||
|
storage?.setItem(TIME_ZONE_STORAGE_KEY, normalized);
|
||||||
|
} catch {
|
||||||
|
// 偏好写入失败不能影响本次大屏筛选。
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOfDay(value) {
|
export function startOfDay(value) {
|
||||||
@ -18,21 +59,35 @@ export function endOfDay(value) {
|
|||||||
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeEndMs(range) {
|
export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59");
|
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeStartMs(range) {
|
export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
return dateTimeMs(range?.start, range?.startTime || "00:00:00");
|
return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
export function formatDateTime(value, timeZone = DEFAULT_TIME_ZONE) {
|
||||||
const date = new Date(Number(value) || Date.now());
|
const millis = Number(value) || Date.now();
|
||||||
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
const parts = Object.fromEntries(
|
||||||
|
getDateTimeFormatter(normalizeTimeZone(timeZone))
|
||||||
|
.formatToParts(millis)
|
||||||
|
.filter((part) => part.type !== "literal")
|
||||||
|
.map((part) => [part.type, part.value])
|
||||||
|
);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateTimeMs(dateValue, timeValue) {
|
export function sameRange(left, right) {
|
||||||
return dateValue ? new Date(`${dateValue}T${normalizeTime(timeValue)}`).getTime() : "";
|
return left?.start === right?.start && left?.end === right?.end && normalizeTime(left?.startTime || "00:00:00") === normalizeTime(right?.startTime || "00:00:00") && normalizeTime(left?.endTime || "23:59:59") === normalizeTime(right?.endTime || "23:59:59");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateTimeMs(dateValue, timeValue, timeZone) {
|
||||||
|
if (!dateValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const suffix = normalizeTimeZone(timeZone) === "UTC" ? "Z" : "+08:00";
|
||||||
|
return Date.parse(`${dateValue}T${normalizeTime(timeValue)}${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTime(value) {
|
function normalizeTime(value) {
|
||||||
@ -40,8 +95,53 @@ function normalizeTime(value) {
|
|||||||
return `${pad2(parts[0] || 0)}:${pad2(parts[1] || 0)}:${pad2(parts[2] || 0)}`;
|
return `${pad2(parts[0] || 0)}:${pad2(parts[1] || 0)}:${pad2(parts[2] || 0)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateInputValue(date) {
|
function dateInputValueInTimeZone(date, timeZone) {
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
const parts = Object.fromEntries(
|
||||||
|
getDateFormatter(normalizeTimeZone(timeZone))
|
||||||
|
.formatToParts(date)
|
||||||
|
.filter((part) => part.type !== "literal")
|
||||||
|
.map((part) => [part.type, part.value])
|
||||||
|
);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDateValue(value, days) {
|
||||||
|
const [year, month, day] = String(value).split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, (day || 1) + days));
|
||||||
|
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateFormatter(timeZone) {
|
||||||
|
if (!dateFormatterCache.has(timeZone)) {
|
||||||
|
dateFormatterCache.set(
|
||||||
|
timeZone,
|
||||||
|
new Intl.DateTimeFormat("en-CA", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
timeZone,
|
||||||
|
year: "numeric"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dateFormatterCache.get(timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateTimeFormatter(timeZone) {
|
||||||
|
if (!dateTimeFormatterCache.has(timeZone)) {
|
||||||
|
dateTimeFormatterCache.set(
|
||||||
|
timeZone,
|
||||||
|
new Intl.DateTimeFormat("en-CA", {
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
timeZone,
|
||||||
|
year: "numeric"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dateTimeFormatterCache.get(timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad2(value) {
|
function pad2(value) {
|
||||||
|
|||||||
16
databi/src/utils/time.test.js
Normal file
16
databi/src/utils/time.test.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { rangeEndMs, rangeStartMs } from "./time.js";
|
||||||
|
|
||||||
|
test("converts the selected date range with UTC boundaries", () => {
|
||||||
|
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
|
||||||
|
|
||||||
|
expect(rangeStartMs(range, "UTC")).toBe(Date.UTC(2026, 5, 6, 0, 0, 0));
|
||||||
|
expect(rangeEndMs(range, "UTC")).toBe(Date.UTC(2026, 5, 6, 23, 59, 59));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts the selected date range with Beijing time boundaries", () => {
|
||||||
|
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
|
||||||
|
|
||||||
|
expect(rangeStartMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 5, 16, 0, 0));
|
||||||
|
expect(rangeEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 15, 59, 59));
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
import { getMenus } from "@/features/menus/api";
|
import { getMenus } from "@/features/menus/api";
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
mapBackendMenus,
|
mapBackendMenus,
|
||||||
mergeNavigationItems
|
mergeNavigationItems
|
||||||
} from "@/app/navigation/menu.js";
|
} from "@/app/navigation/menu.js";
|
||||||
|
import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js";
|
||||||
import { Header } from "./Header.jsx";
|
import { Header } from "./Header.jsx";
|
||||||
import { Sidebar } from "./Sidebar.jsx";
|
import { Sidebar } from "./Sidebar.jsx";
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ export function AdminLayout() {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { can } = useAuth();
|
const { can, user } = useAuth();
|
||||||
const { refresh } = useRefreshSignal();
|
const { refresh } = useRefreshSignal();
|
||||||
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
|
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
|
||||||
errorMessage: "加载菜单失败",
|
errorMessage: "加载菜单失败",
|
||||||
@ -35,6 +36,10 @@ export function AdminLayout() {
|
|||||||
}, [backendMenus, can]);
|
}, [backendMenus, can]);
|
||||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user });
|
||||||
|
}, [location.pathname, menus, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
||||||
<Header
|
<Header
|
||||||
@ -50,7 +55,7 @@ export function AdminLayout() {
|
|||||||
|
|
||||||
<main className="workspace">
|
<main className="workspace">
|
||||||
<div className="page-transition" key={location.pathname}>
|
<div className="page-transition" key={location.pathname}>
|
||||||
<Outlet />
|
<Outlet context={{ menus }} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,12 +20,14 @@ import MapOutlined from "@mui/icons-material/MapOutlined";
|
|||||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||||
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||||
|
import StarBorderOutlined from "@mui/icons-material/StarBorderOutlined";
|
||||||
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
|
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
|
||||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||||
@ -58,6 +60,7 @@ const iconMap = {
|
|||||||
map: MapOutlined,
|
map: MapOutlined,
|
||||||
menu: MenuOpenOutlined,
|
menu: MenuOpenOutlined,
|
||||||
operations: ReceiptLongOutlined,
|
operations: ReceiptLongOutlined,
|
||||||
|
paid: PaidOutlined,
|
||||||
public: PublicOutlined,
|
public: PublicOutlined,
|
||||||
push_pin: PushPinOutlined,
|
push_pin: PushPinOutlined,
|
||||||
receipt: ReceiptLongOutlined,
|
receipt: ReceiptLongOutlined,
|
||||||
@ -67,6 +70,7 @@ const iconMap = {
|
|||||||
settings: SettingsApplicationsOutlined,
|
settings: SettingsApplicationsOutlined,
|
||||||
shield: ShieldOutlined,
|
shield: ShieldOutlined,
|
||||||
sports_esports: SportsEsportsOutlined,
|
sports_esports: SportsEsportsOutlined,
|
||||||
|
star: StarBorderOutlined,
|
||||||
storefront: StorefrontOutlined,
|
storefront: StorefrontOutlined,
|
||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
@ -74,21 +78,25 @@ const iconMap = {
|
|||||||
workspace_premium: WorkspacePremiumOutlined,
|
workspace_premium: WorkspacePremiumOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const topLevelMenuOrder = new Map(
|
||||||
|
[
|
||||||
|
"overview",
|
||||||
|
"app-users",
|
||||||
|
"host-org",
|
||||||
|
"rooms",
|
||||||
|
"app-config",
|
||||||
|
"resources",
|
||||||
|
"operations",
|
||||||
|
"payment",
|
||||||
|
"activities",
|
||||||
|
"games",
|
||||||
|
"geo",
|
||||||
|
"system",
|
||||||
|
].map((code, index) => [code, index]),
|
||||||
|
);
|
||||||
|
|
||||||
export const fallbackNavigation = [
|
export const fallbackNavigation = [
|
||||||
routeNavItem("overview", { icon: DashboardOutlined }),
|
routeNavItem("overview", { icon: DashboardOutlined }),
|
||||||
{
|
|
||||||
code: "system",
|
|
||||||
icon: SettingsApplicationsOutlined,
|
|
||||||
id: "system",
|
|
||||||
label: "后台设置",
|
|
||||||
children: [
|
|
||||||
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
|
||||||
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
|
||||||
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
|
|
||||||
routeNavItem("logs-login", { icon: LoginOutlined }),
|
|
||||||
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
code: "app-users",
|
code: "app-users",
|
||||||
icon: ManageAccountsOutlined,
|
icon: ManageAccountsOutlined,
|
||||||
@ -101,6 +109,22 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "host-org",
|
||||||
|
icon: HubOutlined,
|
||||||
|
id: "host-org",
|
||||||
|
label: "团队管理",
|
||||||
|
children: [
|
||||||
|
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
|
||||||
|
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
||||||
|
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
||||||
|
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
||||||
|
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
|
||||||
|
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
|
||||||
|
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
|
||||||
|
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "rooms",
|
code: "rooms",
|
||||||
icon: BedroomParentOutlined,
|
icon: BedroomParentOutlined,
|
||||||
@ -153,6 +177,13 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "payment",
|
||||||
|
icon: WalletOutlined,
|
||||||
|
id: "payment",
|
||||||
|
label: "支付管理",
|
||||||
|
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "activities",
|
code: "activities",
|
||||||
icon: CampaignOutlined,
|
icon: CampaignOutlined,
|
||||||
@ -162,20 +193,16 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||||
|
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
|
||||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||||
|
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||||
|
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: "payment",
|
|
||||||
icon: WalletOutlined,
|
|
||||||
id: "payment",
|
|
||||||
label: "支付管理",
|
|
||||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
code: "games",
|
code: "games",
|
||||||
icon: SportsEsportsOutlined,
|
icon: SportsEsportsOutlined,
|
||||||
@ -194,25 +221,22 @@ export const fallbackNavigation = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "host-org",
|
code: "system",
|
||||||
icon: HubOutlined,
|
icon: SettingsApplicationsOutlined,
|
||||||
id: "host-org",
|
id: "system",
|
||||||
label: "团队管理",
|
label: "后台设置",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
|
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
||||||
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
|
||||||
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
routeNavItem("logs-login", { icon: LoginOutlined }),
|
||||||
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
|
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
|
|
||||||
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
|
|
||||||
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function mapBackendMenus(menus = []) {
|
export function mapBackendMenus(menus = []) {
|
||||||
return relocateBackendMenus(menus)
|
return orderTopLevelMenus(relocateBackendMenus(menus))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (deprecatedMenuCodes.has(item.code)) {
|
if (deprecatedMenuCodes.has(item.code)) {
|
||||||
return null;
|
return null;
|
||||||
@ -238,6 +262,17 @@ function relocateBackendMenus(menus = []) {
|
|||||||
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
|
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderTopLevelMenus(menus = []) {
|
||||||
|
return menus
|
||||||
|
.map((item, index) => ({ index, item }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = topLevelMenuOrder.get(left.item.code) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightOrder = topLevelMenuOrder.get(right.item.code) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
}
|
||||||
|
|
||||||
function relocateRechargeProductsToAppConfig(menus = []) {
|
function relocateRechargeProductsToAppConfig(menus = []) {
|
||||||
const appConfigMenu = menus.find((item) => item.code === "app-config");
|
const appConfigMenu = menus.find((item) => item.code === "app-config");
|
||||||
const paymentMenu = menus.find((item) => item.code === "payment");
|
const paymentMenu = menus.find((item) => item.code === "payment");
|
||||||
|
|||||||
@ -2,6 +2,35 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
|
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
|
||||||
|
|
||||||
describe("navigation menu helpers", () => {
|
describe("navigation menu helpers", () => {
|
||||||
|
test("keeps requested parent menu order in fallback navigation", () => {
|
||||||
|
expect(fallbackNavigation.map((item) => item.code)).toEqual([
|
||||||
|
"overview",
|
||||||
|
"app-users",
|
||||||
|
"host-org",
|
||||||
|
"rooms",
|
||||||
|
"app-config",
|
||||||
|
"resources",
|
||||||
|
"operations",
|
||||||
|
"payment",
|
||||||
|
"activities",
|
||||||
|
"games",
|
||||||
|
"geo",
|
||||||
|
"system",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes requested parent menu order from backend menus", () => {
|
||||||
|
const mapped = mapBackendMenus([
|
||||||
|
{ children: [], code: "system", icon: "settings", id: "system", label: "后台设置" },
|
||||||
|
{ children: [], code: "geo", icon: "map", id: "geo", label: "地区管理" },
|
||||||
|
{ children: [], code: "host-org", icon: "network", id: "host-org", label: "团队管理" },
|
||||||
|
{ children: [], code: "app-users", icon: "users", id: "app-users", label: "用户管理" },
|
||||||
|
{ children: [], code: "overview", icon: "dashboard", id: "overview", label: "总览" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mapped.map((item) => item.code)).toEqual(["overview", "app-users", "host-org", "geo", "system"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps only fallback menu items allowed by permissions", () => {
|
test("keeps only fallback menu items allowed by permissions", () => {
|
||||||
const items = filterNavigationByPermission(
|
const items = filterNavigationByPermission(
|
||||||
fallbackNavigation,
|
fallbackNavigation,
|
||||||
|
|||||||
124
src/app/navigation/menuUsage.js
Normal file
124
src/app/navigation/menuUsage.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { getSelectedAppCode } from "@/shared/api/request";
|
||||||
|
|
||||||
|
const storagePrefix = "hyapp-admin.menu-usage.v1";
|
||||||
|
const maxStoredItems = 80;
|
||||||
|
const dedupeWindowMs = 1000;
|
||||||
|
|
||||||
|
export function recordSecondLevelMenuVisit({ appCode, menus = [], now = Date.now(), pathname, storage, user }) {
|
||||||
|
const safeStorage = storage || localStorageOrNull();
|
||||||
|
if (!safeStorage || !user || !pathname) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const matchedItem = findSecondLevelNavItemByPath(pathname, menus);
|
||||||
|
if (!matchedItem?.path) {
|
||||||
|
return getFrequentSecondLevelMenus({ appCode, menus, storage: safeStorage, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = menuUsageStorageKey(user, appCode);
|
||||||
|
const currentItems = readUsageItems(storageKey, safeStorage);
|
||||||
|
const previous = currentItems.find((item) => item.code === matchedItem.code);
|
||||||
|
const previousVisitAt = Number(previous?.lastVisitedAtMs || 0);
|
||||||
|
const shouldCount = previous?.path !== matchedItem.path || now - previousVisitAt > dedupeWindowMs;
|
||||||
|
const nextItem = {
|
||||||
|
code: matchedItem.code,
|
||||||
|
count: (Number(previous?.count || 0) || 0) + (shouldCount ? 1 : 0),
|
||||||
|
label: matchedItem.label,
|
||||||
|
lastVisitedAtMs: now,
|
||||||
|
parentCode: matchedItem.parentCode,
|
||||||
|
parentLabel: matchedItem.parentLabel,
|
||||||
|
path: matchedItem.path,
|
||||||
|
};
|
||||||
|
const nextItems = [nextItem, ...currentItems.filter((item) => item.code !== matchedItem.code)]
|
||||||
|
.sort(compareUsageItems)
|
||||||
|
.slice(0, maxStoredItems);
|
||||||
|
writeUsageItems(storageKey, nextItems, safeStorage);
|
||||||
|
return getFrequentSecondLevelMenus({ appCode, menus, storage: safeStorage, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrequentSecondLevelMenus({ appCode, limit = 8, menus = [], storage, user }) {
|
||||||
|
const safeStorage = storage || localStorageOrNull();
|
||||||
|
if (!safeStorage || !user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const itemByCode = new Map(flattenSecondLevelNavItems(menus).map((item) => [item.code, item]));
|
||||||
|
return readUsageItems(menuUsageStorageKey(user, appCode), safeStorage)
|
||||||
|
.sort(compareUsageItems)
|
||||||
|
.map((item) => {
|
||||||
|
const currentItem = itemByCode.get(item.code);
|
||||||
|
if (!currentItem?.path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
icon: currentItem.icon,
|
||||||
|
label: currentItem.label,
|
||||||
|
parentLabel: currentItem.parentLabel,
|
||||||
|
path: currentItem.path,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSecondLevelNavItemByPath(pathname, menus = []) {
|
||||||
|
return flattenSecondLevelNavItems(menus)
|
||||||
|
.filter((item) => isActiveRoute(pathname, item.path))
|
||||||
|
.sort((left, right) => right.path.length - left.path.length)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenSecondLevelNavItems(menus = []) {
|
||||||
|
return menus.flatMap((parent) =>
|
||||||
|
(parent.children || [])
|
||||||
|
.filter((child) => child.path)
|
||||||
|
.map((child) => ({
|
||||||
|
...child,
|
||||||
|
parentCode: parent.code,
|
||||||
|
parentLabel: parent.label,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuUsageStorageKey(user, appCode) {
|
||||||
|
const scope = user?.id || user?.account || "anonymous";
|
||||||
|
const app = appCode || getSelectedAppCode() || "default";
|
||||||
|
return `${storagePrefix}.${app}.${scope}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUsageItems(storageKey, storage) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(storage.getItem(storageKey) || "{}");
|
||||||
|
return Array.isArray(payload.items) ? payload.items.filter((item) => item?.code && item?.path) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUsageItems(storageKey, items, storage) {
|
||||||
|
storage.setItem(storageKey, JSON.stringify({ items, updatedAtMs: Date.now(), version: 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareUsageItems(left, right) {
|
||||||
|
const countDiff = Number(right.count || 0) - Number(left.count || 0);
|
||||||
|
if (countDiff !== 0) {
|
||||||
|
return countDiff;
|
||||||
|
}
|
||||||
|
return Number(right.lastVisitedAtMs || 0) - Number(left.lastVisitedAtMs || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveRoute(activePath, itemPath) {
|
||||||
|
if (!itemPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (activePath === itemPath) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return activePath.startsWith(`${itemPath}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localStorageOrNull() {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/navigation/menuUsage.test.js
Normal file
69
src/app/navigation/menuUsage.test.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
findSecondLevelNavItemByPath,
|
||||||
|
getFrequentSecondLevelMenus,
|
||||||
|
recordSecondLevelMenuVisit,
|
||||||
|
} from "@/app/navigation/menuUsage.js";
|
||||||
|
|
||||||
|
const user = { account: "admin", id: 1 };
|
||||||
|
const appCode = "lalu";
|
||||||
|
const menus = [
|
||||||
|
{ code: "overview", id: "overview", label: "系统概览", path: "/overview" },
|
||||||
|
{
|
||||||
|
code: "app-users",
|
||||||
|
id: "app-users",
|
||||||
|
label: "用户管理",
|
||||||
|
children: [
|
||||||
|
{ code: "app-user-list", id: "app-user-list", label: "用户列表", path: "/app/users" },
|
||||||
|
{ code: "app-user-login-logs", id: "app-user-login-logs", label: "登录日志", path: "/app/users/login-logs" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "resources",
|
||||||
|
id: "resources",
|
||||||
|
label: "资源管理",
|
||||||
|
children: [{ code: "gift-list", id: "gift-list", label: "礼物列表", path: "/resources/gifts" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("menu usage cache", () => {
|
||||||
|
test("matches the best second-level menu for nested paths", () => {
|
||||||
|
expect(findSecondLevelNavItemByPath("/app/users/login-logs/123", menus)).toMatchObject({
|
||||||
|
code: "app-user-login-logs",
|
||||||
|
parentLabel: "用户管理",
|
||||||
|
});
|
||||||
|
expect(findSecondLevelNavItemByPath("/overview", menus)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records second-level visits and returns frequent cards", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 1000, pathname: "/app/users", storage, user });
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 2500, pathname: "/resources/gifts", storage, user });
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 4000, pathname: "/app/users", storage, user });
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 4100, pathname: "/app/users", storage, user });
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 6000, pathname: "/overview", storage, user });
|
||||||
|
|
||||||
|
const items = getFrequentSecondLevelMenus({ appCode, menus, storage, user });
|
||||||
|
expect(items.map((item) => [item.code, item.count])).toEqual([
|
||||||
|
["app-user-list", 2],
|
||||||
|
["gift-list", 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops cached menus that are no longer visible", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
recordSecondLevelMenuVisit({ appCode, menus, now: 1000, pathname: "/resources/gifts", storage, user });
|
||||||
|
|
||||||
|
const visibleMenus = menus.filter((item) => item.code !== "resources");
|
||||||
|
expect(getFrequentSecondLevelMenus({ appCode, menus: visibleMenus, storage, user })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
getItem: (key) => values.get(key) || null,
|
||||||
|
removeItem: (key) => values.delete(key),
|
||||||
|
setItem: (key, value) => values.set(key, String(value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -124,18 +124,27 @@ export const PERMISSIONS = {
|
|||||||
registrationRewardUpdate: "registration-reward:update",
|
registrationRewardUpdate: "registration-reward:update",
|
||||||
firstRechargeRewardView: "first-recharge-reward:view",
|
firstRechargeRewardView: "first-recharge-reward:view",
|
||||||
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||||
|
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
|
||||||
|
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
|
||||||
sevenDayCheckInView: "seven-day-checkin:view",
|
sevenDayCheckInView: "seven-day-checkin:view",
|
||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
roomRocketView: "room-rocket:view",
|
roomRocketView: "room-rocket:view",
|
||||||
roomRocketUpdate: "room-rocket:update",
|
roomRocketUpdate: "room-rocket:update",
|
||||||
|
roomTurnoverRewardView: "room-turnover-reward:view",
|
||||||
|
roomTurnoverRewardUpdate: "room-turnover-reward:update",
|
||||||
|
roomTurnoverRewardRetry: "room-turnover-reward:retry",
|
||||||
userLeaderboardView: "user-leaderboard:view",
|
userLeaderboardView: "user-leaderboard:view",
|
||||||
redPacketView: "red-packet:view",
|
redPacketView: "red-packet:view",
|
||||||
redPacketUpdate: "red-packet:update",
|
redPacketUpdate: "red-packet:update",
|
||||||
vipConfigView: "vip-config:view",
|
vipConfigView: "vip-config:view",
|
||||||
vipConfigUpdate: "vip-config:update",
|
vipConfigUpdate: "vip-config:update",
|
||||||
vipConfigGrant: "vip-config:grant",
|
vipConfigGrant: "vip-config:grant",
|
||||||
|
weeklyStarView: "weekly-star:view",
|
||||||
|
weeklyStarCreate: "weekly-star:create",
|
||||||
|
weeklyStarUpdate: "weekly-star:update",
|
||||||
|
weeklyStarSettle: "weekly-star:settle",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -185,12 +194,15 @@ export const MENU_CODES = {
|
|||||||
dailyTaskList: "daily-task-list",
|
dailyTaskList: "daily-task-list",
|
||||||
registrationReward: "registration-reward",
|
registrationReward: "registration-reward",
|
||||||
firstRechargeReward: "first-recharge-reward",
|
firstRechargeReward: "first-recharge-reward",
|
||||||
|
cumulativeRechargeReward: "cumulative-recharge-reward",
|
||||||
achievementConfig: "achievement-config",
|
achievementConfig: "achievement-config",
|
||||||
sevenDayCheckIn: "seven-day-checkin",
|
sevenDayCheckIn: "seven-day-checkin",
|
||||||
roomRocket: "room-rocket",
|
roomRocket: "room-rocket",
|
||||||
|
roomTurnoverReward: "room-turnover-reward",
|
||||||
userLeaderboard: "user-leaderboard",
|
userLeaderboard: "user-leaderboard",
|
||||||
redPacket: "red-packet",
|
redPacket: "red-packet",
|
||||||
vipConfig: "vip-config",
|
vipConfig: "vip-config",
|
||||||
|
weeklyStar: "weekly-star",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { authRoutes } from "@/features/auth/routes.js";
|
|||||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
|
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||||
@ -21,10 +22,12 @@ import { resourceRoutes } from "@/features/resources/routes.js";
|
|||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
||||||
|
import { roomTurnoverRewardRoutes } from "@/features/room-turnover-reward/routes.js";
|
||||||
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
|
||||||
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
|
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
|
||||||
import { usersRoutes } from "@/features/users/routes.js";
|
import { usersRoutes } from "@/features/users/routes.js";
|
||||||
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
|
||||||
|
import { weeklyStarRoutes } from "@/features/weekly-star/routes.js";
|
||||||
import type { MenuCode } from "@/app/permissions";
|
import type { MenuCode } from "@/app/permissions";
|
||||||
import type { AdminRoute, PublicRoute } from "./types";
|
import type { AdminRoute, PublicRoute } from "./types";
|
||||||
|
|
||||||
@ -41,8 +44,11 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...achievementRoutes,
|
...achievementRoutes,
|
||||||
...registrationRewardRoutes,
|
...registrationRewardRoutes,
|
||||||
...firstRechargeRewardRoutes,
|
...firstRechargeRewardRoutes,
|
||||||
|
...cumulativeRechargeRewardRoutes,
|
||||||
...sevenDayCheckInRoutes,
|
...sevenDayCheckInRoutes,
|
||||||
...roomRocketRoutes,
|
...roomRocketRoutes,
|
||||||
|
...roomTurnoverRewardRoutes,
|
||||||
|
...weeklyStarRoutes,
|
||||||
...userLeaderboardRoutes,
|
...userLeaderboardRoutes,
|
||||||
...redPacketRoutes,
|
...redPacketRoutes,
|
||||||
...vipConfigRoutes,
|
...vipConfigRoutes,
|
||||||
|
|||||||
35
src/features/app-users/api.test.ts
Normal file
35
src/features/app-users/api.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
|
import { listAppUsers } from "./api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAccessToken("");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listAppUsers sends country region and time filters", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listAppUsers({
|
||||||
|
country: "PH",
|
||||||
|
end_ms: 2000,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
region_id: 3,
|
||||||
|
start_ms: 1000,
|
||||||
|
});
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
expect(String(url)).toContain("/api/v1/app/users?");
|
||||||
|
expect(String(url)).toContain("country=PH");
|
||||||
|
expect(String(url)).toContain("region_id=3");
|
||||||
|
expect(String(url)).toContain("start_ms=1000");
|
||||||
|
expect(String(url)).toContain("end_ms=2000");
|
||||||
|
expect(init?.method).toBe("GET");
|
||||||
|
});
|
||||||
@ -40,12 +40,26 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarLeft {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.selectedMeta {
|
.selectedMeta {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeFilter {
|
||||||
|
flex: 0 1 280px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.selectColumn {
|
.selectColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -244,13 +258,15 @@
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.toolbar,
|
.toolbar,
|
||||||
|
.toolbarLeft,
|
||||||
.filters {
|
.filters {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search,
|
.search,
|
||||||
.statusSelect {
|
.statusSelect,
|
||||||
|
.timeFilter {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
|
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
|
||||||
@ -19,8 +20,12 @@ export function useAppUsersPage() {
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||||
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [regionId, setRegionId] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
const [sortBy, setSortBy] = useState("created_at");
|
const [sortBy, setSortBy] = useState("created_at");
|
||||||
const [sortDirection, setSortDirection] = useState("desc");
|
const [sortDirection, setSortDirection] = useState("desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -34,12 +39,16 @@ export function useAppUsersPage() {
|
|||||||
const [patchedUsers, setPatchedUsers] = useState({});
|
const [patchedUsers, setPatchedUsers] = useState({});
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
country,
|
||||||
keyword: query,
|
keyword: query,
|
||||||
|
region_id: regionId,
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
sort_direction: sortDirection,
|
sort_direction: sortDirection,
|
||||||
|
end_ms: timeRange.endMs,
|
||||||
status,
|
status,
|
||||||
|
start_ms: timeRange.startMs,
|
||||||
}),
|
}),
|
||||||
[query, sortBy, sortDirection, status],
|
[country, query, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data: queryData = emptyData,
|
data: queryData = emptyData,
|
||||||
@ -65,7 +74,24 @@ export function useAppUsersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setPatchedUsers({});
|
setPatchedUsers({});
|
||||||
}, [page, query, sortBy, sortDirection, status]);
|
}, [country, page, query, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs]);
|
||||||
|
|
||||||
|
const locationFilterOptions = useMemo(() => {
|
||||||
|
const countryItems = countryOptions.map((option) => ({
|
||||||
|
label: `国家 · ${option.label}`,
|
||||||
|
value: `country:${option.value}`,
|
||||||
|
}));
|
||||||
|
const regionItems = [
|
||||||
|
{ label: "区域 · GLOBAL · 0", value: "region:0" },
|
||||||
|
...regionOptions.map((option) => ({
|
||||||
|
label: `区域 · ${option.label}`,
|
||||||
|
value: `region:${option.value}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return [...countryItems, ...regionItems];
|
||||||
|
}, [countryOptions, regionOptions]);
|
||||||
|
|
||||||
|
const locationFilterValue = country ? `country:${country}` : regionId !== "" ? `region:${regionId}` : "";
|
||||||
|
|
||||||
const changeQuery = (value) => {
|
const changeQuery = (value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
@ -77,6 +103,26 @@ export function useAppUsersPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeLocationFilter = (value) => {
|
||||||
|
const [kind, rawValue = ""] = String(value || "").split(":");
|
||||||
|
if (kind === "country" && rawValue) {
|
||||||
|
setCountry(rawValue);
|
||||||
|
setRegionId("");
|
||||||
|
} else if (kind === "region") {
|
||||||
|
setCountry("");
|
||||||
|
setRegionId(rawValue);
|
||||||
|
} else {
|
||||||
|
setCountry("");
|
||||||
|
setRegionId("");
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTimeRange = (value) => {
|
||||||
|
setTimeRange(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const changeSort = (field) => {
|
const changeSort = (field) => {
|
||||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||||
setSortBy(field);
|
setSortBy(field);
|
||||||
@ -84,8 +130,11 @@ export function useAppUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
|
setCountry("");
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setRegionId("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -243,6 +292,7 @@ export function useAppUsersPage() {
|
|||||||
changeQuery,
|
changeQuery,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAction,
|
closeAction,
|
||||||
|
changeLocationFilter,
|
||||||
countryForm,
|
countryForm,
|
||||||
countryOptions,
|
countryOptions,
|
||||||
data,
|
data,
|
||||||
@ -251,12 +301,17 @@ export function useAppUsersPage() {
|
|||||||
loading,
|
loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
loadingCountries,
|
loadingCountries,
|
||||||
|
loadingRegions,
|
||||||
|
locationFilterOptions,
|
||||||
|
locationFilterValue,
|
||||||
openCountry,
|
openCountry,
|
||||||
openEdit,
|
openEdit,
|
||||||
openPassword,
|
openPassword,
|
||||||
page,
|
page,
|
||||||
passwordForm,
|
passwordForm,
|
||||||
query,
|
query,
|
||||||
|
regionId,
|
||||||
|
regionOptions,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
selectedUserIds,
|
selectedUserIds,
|
||||||
@ -268,7 +323,9 @@ export function useAppUsersPage() {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
status,
|
status,
|
||||||
|
timeRange,
|
||||||
changeSort,
|
changeSort,
|
||||||
|
changeTimeRange,
|
||||||
submitCountry,
|
submitCountry,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
submitPassword,
|
submitPassword,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ 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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
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";
|
||||||
@ -95,6 +96,14 @@ export function AppUserListPage() {
|
|||||||
key: "location",
|
key: "location",
|
||||||
label: "国家 / 区域",
|
label: "国家 / 区域",
|
||||||
width: "minmax(170px, 1fr)",
|
width: "minmax(170px, 1fr)",
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部国家 / 区域",
|
||||||
|
loading: page.loadingCountries || page.loadingRegions,
|
||||||
|
options: page.locationFilterOptions,
|
||||||
|
placeholder: "搜索国家或区域",
|
||||||
|
value: page.locationFilterValue,
|
||||||
|
onChange: page.changeLocationFilter,
|
||||||
|
}),
|
||||||
render: (user) => <UserLocation page={page} user={user} />,
|
render: (user) => <UserLocation page={page} user={user} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -157,9 +166,19 @@ export function AppUserListPage() {
|
|||||||
<div className={styles.contentPanel}>
|
<div className={styles.contentPanel}>
|
||||||
<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}>
|
||||||
{page.abilities.canStatus ? (
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbarLeft}>
|
||||||
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} 个</span>
|
{page.abilities.canStatus ? (
|
||||||
|
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} 个</span>
|
||||||
|
) : null}
|
||||||
|
<TimeRangeFilter
|
||||||
|
className={styles.timeFilter}
|
||||||
|
label="创建时间"
|
||||||
|
value={page.timeRange}
|
||||||
|
onChange={page.changeTimeRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{page.abilities.canStatus ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
||||||
startIcon={<BlockOutlined fontSize="small" />}
|
startIcon={<BlockOutlined fontSize="small" />}
|
||||||
@ -168,8 +187,8 @@ export function AppUserListPage() {
|
|||||||
>
|
>
|
||||||
批量封禁
|
批量封禁
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
176
src/features/cumulative-recharge-reward/api.ts
Normal file
176
src/features/cumulative-recharge-reward/api.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export interface CumulativeRechargeRewardTierDto {
|
||||||
|
tierId: number;
|
||||||
|
tierCode: string;
|
||||||
|
tierName: string;
|
||||||
|
thresholdUsdMinor: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
status: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CumulativeRechargeRewardConfigDto {
|
||||||
|
appCode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: CumulativeRechargeRewardTierDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CumulativeRechargeRewardConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: Array<{
|
||||||
|
tier_id?: number;
|
||||||
|
tier_code: string;
|
||||||
|
tier_name: string;
|
||||||
|
threshold_usd_minor: number;
|
||||||
|
resource_group_id: number;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CumulativeRechargeRewardGrantUserDto {
|
||||||
|
userId?: number;
|
||||||
|
displayUserId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CumulativeRechargeRewardGrantDto {
|
||||||
|
grantId: string;
|
||||||
|
appCode?: string;
|
||||||
|
cycleKey?: string;
|
||||||
|
eventId?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
commandId?: string;
|
||||||
|
userId: number;
|
||||||
|
user?: CumulativeRechargeRewardGrantUserDto;
|
||||||
|
tierId?: number;
|
||||||
|
tierCode?: string;
|
||||||
|
resourceGroupId?: number;
|
||||||
|
thresholdUsdMinor?: number;
|
||||||
|
reachedUsdMinor?: number;
|
||||||
|
qualifyingUsdMinor?: number;
|
||||||
|
rechargeCoinAmount?: number;
|
||||||
|
rechargeType?: string;
|
||||||
|
status?: string;
|
||||||
|
walletCommandId?: string;
|
||||||
|
walletGrantId?: string;
|
||||||
|
failureReason?: string;
|
||||||
|
grantedAtMs?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawConfig = CumulativeRechargeRewardConfigDto & Record<string, unknown>;
|
||||||
|
type RawTier = CumulativeRechargeRewardTierDto & Record<string, unknown>;
|
||||||
|
type RawGrant = CumulativeRechargeRewardGrantDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getCumulativeRechargeRewardConfig(): Promise<CumulativeRechargeRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getCumulativeRechargeRewardConfig;
|
||||||
|
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getCumulativeRechargeRewardConfig), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCumulativeRechargeRewardConfig(
|
||||||
|
payload: CumulativeRechargeRewardConfigPayload,
|
||||||
|
): Promise<CumulativeRechargeRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateCumulativeRechargeRewardConfig;
|
||||||
|
return apiRequest<RawConfig, CumulativeRechargeRewardConfigPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateCumulativeRechargeRewardConfig),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCumulativeRechargeRewardGrants(
|
||||||
|
query: PageQuery = {},
|
||||||
|
): Promise<ApiPage<CumulativeRechargeRewardGrantDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listCumulativeRechargeRewardGrants;
|
||||||
|
return apiRequest<ApiPage<RawGrant>>(apiEndpointPath(API_OPERATIONS.listCumulativeRechargeRewardGrants), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeGrant),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(item: RawConfig): CumulativeRechargeRewardConfigDto {
|
||||||
|
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
tiers: rawTiers.map(normalizeTier),
|
||||||
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTier(item: RawTier): CumulativeRechargeRewardTierDto {
|
||||||
|
return {
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||||
|
thresholdUsdMinor: numberValue(item.thresholdUsdMinor ?? item.threshold_usd_minor),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
|
||||||
|
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
grantId: stringValue(item.grantId ?? item.grant_id),
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
|
||||||
|
eventId: stringValue(item.eventId ?? item.event_id),
|
||||||
|
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||||
|
commandId: stringValue(item.commandId ?? item.command_id),
|
||||||
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
|
user: {
|
||||||
|
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||||
|
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||||
|
username: stringValue(rawUser.username),
|
||||||
|
avatar: stringValue(rawUser.avatar),
|
||||||
|
},
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
thresholdUsdMinor: numberValue(item.thresholdUsdMinor ?? item.threshold_usd_minor),
|
||||||
|
reachedUsdMinor: numberValue(item.reachedUsdMinor ?? item.reached_usd_minor),
|
||||||
|
qualifyingUsdMinor: numberValue(item.qualifyingUsdMinor ?? item.qualifying_usd_minor),
|
||||||
|
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||||
|
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||||
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
|
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["active", "启用"],
|
||||||
|
["inactive", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CumulativeRechargeRewardConfigDrawer({
|
||||||
|
abilities,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
}) {
|
||||||
|
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||||
|
|
||||||
|
const addTier = () => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: [
|
||||||
|
...(current.tiers || []),
|
||||||
|
{
|
||||||
|
tierId: 0,
|
||||||
|
tierCode: "",
|
||||||
|
tierName: "",
|
||||||
|
thresholdUsd: "",
|
||||||
|
resourceGroupId: "",
|
||||||
|
status: "active",
|
||||||
|
sortOrder: String((current.tiers || []).length),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTier = (index, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTier = (index) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||||
|
<form className="form-drawer" onSubmit={onSubmit}>
|
||||||
|
<h2>累充奖励配置</h2>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className="form-drawer__section-title">发放状态</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
checkedLabel="开启"
|
||||||
|
disabled={disabled}
|
||||||
|
label="累充奖励状态"
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className={styles.drawerSectionTitle}>
|
||||||
|
<span>奖励档位</span>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={addTier}
|
||||||
|
>
|
||||||
|
添加档位
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tierEditorList}>
|
||||||
|
{(form.tiers || []).map((tier, index) => (
|
||||||
|
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||||
|
<div className={styles.tierEditorHeader}>
|
||||||
|
<span>档位 {index + 1}</span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除档位"
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeTier(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位编码"
|
||||||
|
value={tier.tierCode}
|
||||||
|
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位名称"
|
||||||
|
value={tier.tierName}
|
||||||
|
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
label="充值金额 USD"
|
||||||
|
type="number"
|
||||||
|
value={tier.thresholdUsd}
|
||||||
|
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
|
||||||
|
/>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled}
|
||||||
|
drawerTitle="选择累充奖励资源组"
|
||||||
|
groups={resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
value={tier.resourceGroupId}
|
||||||
|
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
disabled={disabled}
|
||||||
|
label="状态"
|
||||||
|
value={tier.status}
|
||||||
|
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||||
|
>
|
||||||
|
{statusOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={tier.sortOrder}
|
||||||
|
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="form-drawer__actions">
|
||||||
|
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<SaveOutlined fontSize="small" />}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
||||||
|
|
||||||
|
export function CumulativeRechargeRewardConfigSummary({
|
||||||
|
canUpdate,
|
||||||
|
config,
|
||||||
|
configLoading,
|
||||||
|
resourceGroups,
|
||||||
|
onEdit,
|
||||||
|
onRefresh,
|
||||||
|
}) {
|
||||||
|
const tiers = config?.tiers || [];
|
||||||
|
const activeTiers = tiers.filter((tier) => tier.status === "active");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.summaryPanel}>
|
||||||
|
<div className={styles.summaryItems}>
|
||||||
|
<SummaryItem label="状态">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
styles.statusBadge,
|
||||||
|
config?.enabled ? styles.statusActive : styles.statusInactive,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{config?.enabled ? "启用" : "停用"}
|
||||||
|
</span>
|
||||||
|
</SummaryItem>
|
||||||
|
<SummaryItem label="档位">
|
||||||
|
{activeTiers.length}/{tiers.length}
|
||||||
|
</SummaryItem>
|
||||||
|
<SummaryItem label="金额">{thresholdSummary(activeTiers)}</SummaryItem>
|
||||||
|
<SummaryItem label="资源组">{resourceGroupSummary(tiers, resourceGroups)}</SummaryItem>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryActions}>
|
||||||
|
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{canUpdate ? (
|
||||||
|
<Button
|
||||||
|
disabled={configLoading}
|
||||||
|
startIcon={<EditOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
编辑配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryItem({ children, label }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>{label}</span>
|
||||||
|
<span className={styles.summaryValue}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function thresholdSummary(tiers) {
|
||||||
|
if (!tiers.length) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const thresholds = tiers.map((tier) => tier.thresholdUsdMinor || 0).filter((value) => value > 0);
|
||||||
|
if (!thresholds.length) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const min = Math.min(...thresholds);
|
||||||
|
const max = Math.max(...thresholds);
|
||||||
|
return min === max ? formatUSD(min) : `${formatUSD(min)} - ${formatUSD(max)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceGroupSummary(tiers, resourceGroups) {
|
||||||
|
if (!tiers.length) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const names = tiers.slice(0, 2).map((tier) =>
|
||||||
|
groupName(
|
||||||
|
resourceGroups.find((group) => group.groupId === tier.resourceGroupId),
|
||||||
|
tier.resourceGroupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = tiers.length > names.length ? ` +${tiers.length - names.length}` : "";
|
||||||
|
return `${names.join("、")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupName(group, fallbackId) {
|
||||||
|
return group?.name || group?.groupCode || `资源组 #${fallbackId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUSD(value) {
|
||||||
|
return `$${(Number(value || 0) / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
.summaryPanel {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions,
|
||||||
|
.summaryItems {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusActive {
|
||||||
|
background: var(--success-surface);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusInactive {
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerSectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorList {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditor {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
min-height: 92px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.summaryPanel {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { listResourceGroups } from "@/features/resources/api";
|
||||||
|
import {
|
||||||
|
getCumulativeRechargeRewardConfig,
|
||||||
|
listCumulativeRechargeRewardGrants,
|
||||||
|
updateCumulativeRechargeRewardConfig,
|
||||||
|
} from "@/features/cumulative-recharge-reward/api";
|
||||||
|
import { useCumulativeRechargeRewardAbilities } from "@/features/cumulative-recharge-reward/permissions.js";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
const emptyGrants = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
enabled: false,
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCumulativeRechargeRewardPage() {
|
||||||
|
const abilities = useCumulativeRechargeRewardAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [cycleKey, setCycleKey] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const filters = useMemo(() => ({ keyword: query, cycle_key: cycleKey, status }), [cycleKey, query, status]);
|
||||||
|
const {
|
||||||
|
data: grants = emptyGrants,
|
||||||
|
error: grantsError,
|
||||||
|
loading: grantsLoading,
|
||||||
|
reload: reloadGrants,
|
||||||
|
} = usePaginatedQuery({
|
||||||
|
errorMessage: "加载累充奖励记录失败",
|
||||||
|
fetcher: listCumulativeRechargeRewardGrants,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["cumulative-recharge-reward-grants", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
setConfigLoading(true);
|
||||||
|
try {
|
||||||
|
const [config, groups] = await Promise.all([
|
||||||
|
getCumulativeRechargeRewardConfig(),
|
||||||
|
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
||||||
|
]);
|
||||||
|
setConfig(config);
|
||||||
|
setForm(formFromConfig(config));
|
||||||
|
setResourceGroups(groups.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载累充奖励配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadConfig();
|
||||||
|
}, [reloadConfig]);
|
||||||
|
|
||||||
|
const openConfigDrawer = useCallback(() => {
|
||||||
|
setForm(config ? formFromConfig(config) : emptyForm);
|
||||||
|
setConfigDrawerOpen(true);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const closeConfigDrawer = useCallback(() => {
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
|
setForm(config ? formFromConfig(config) : emptyForm);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const changeQuery = (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCycleKey = (value) => {
|
||||||
|
setCycleKey(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfig = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await updateCumulativeRechargeRewardConfig(payloadFromForm(form));
|
||||||
|
setConfig(saved);
|
||||||
|
setForm(formFromConfig(saved));
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
|
showToast("累充奖励配置已保存", "success");
|
||||||
|
await reloadGrants();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存累充奖励配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
changeCycleKey,
|
||||||
|
changeQuery,
|
||||||
|
changeStatus,
|
||||||
|
closeConfigDrawer,
|
||||||
|
config,
|
||||||
|
configDrawerOpen,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
cycleKey,
|
||||||
|
form,
|
||||||
|
grants,
|
||||||
|
grantsError,
|
||||||
|
grantsLoading,
|
||||||
|
openConfigDrawer,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
reloadConfig,
|
||||||
|
reloadGrants,
|
||||||
|
resourceGroups,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
status,
|
||||||
|
submitConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromConfig(config) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
tiers: (config.tiers || []).map((tier) => ({
|
||||||
|
tierId: tier.tierId || 0,
|
||||||
|
tierCode: tier.tierCode || "",
|
||||||
|
tierName: tier.tierName || "",
|
||||||
|
thresholdUsd: tier.thresholdUsdMinor ? String((tier.thresholdUsdMinor / 100).toFixed(2)) : "",
|
||||||
|
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
|
||||||
|
status: tier.status || "active",
|
||||||
|
sortOrder: String(tier.sortOrder || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
const tiers = (form.tiers || []).map((tier, index) => {
|
||||||
|
const thresholdUsdMinor = Math.round(Number(tier.thresholdUsd || 0) * 100);
|
||||||
|
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||||
|
if (!tier.tierCode.trim()) {
|
||||||
|
throw new Error("档位编码不能为空");
|
||||||
|
}
|
||||||
|
if (!tier.tierName.trim()) {
|
||||||
|
throw new Error("档位名称不能为空");
|
||||||
|
}
|
||||||
|
if (thresholdUsdMinor <= 0) {
|
||||||
|
throw new Error("充值 USD 金额必须大于 0");
|
||||||
|
}
|
||||||
|
if (resourceGroupId <= 0) {
|
||||||
|
throw new Error("请选择资源组");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tier_id: Number(tier.tierId || 0),
|
||||||
|
tier_code: tier.tierCode.trim(),
|
||||||
|
tier_name: tier.tierName.trim(),
|
||||||
|
threshold_usd_minor: thresholdUsdMinor,
|
||||||
|
resource_group_id: resourceGroupId,
|
||||||
|
status: tier.status === "inactive" ? "inactive" : "active",
|
||||||
|
sort_order: Number(tier.sortOrder || index),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
|
||||||
|
throw new Error("开启后至少需要一个启用档位");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
tiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import { CumulativeRechargeRewardConfigDrawer } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx";
|
||||||
|
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
|
||||||
|
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
|
||||||
|
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
||||||
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const grantStatusOptions = [
|
||||||
|
["pending", "发放中"],
|
||||||
|
["granted", "已发放"],
|
||||||
|
["failed", "发放失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "用户信息",
|
||||||
|
width: "minmax(260px, 1.1fr)",
|
||||||
|
render: (grant) => <GrantUser grant={grant} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cycle",
|
||||||
|
label: "周期",
|
||||||
|
width: "minmax(150px, 0.7fr)",
|
||||||
|
render: (grant) => grant.cycleKey || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tier",
|
||||||
|
label: "命中档位",
|
||||||
|
width: "minmax(190px, 0.85fr)",
|
||||||
|
render: (grant) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{grant.tierCode || "-"}</span>
|
||||||
|
<span className={styles.meta}>{formatUSD(grant.thresholdUsdMinor)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "amount",
|
||||||
|
label: "累计 / 本次",
|
||||||
|
width: "minmax(210px, 0.9fr)",
|
||||||
|
render: (grant) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{formatUSD(grant.reachedUsdMinor)}</span>
|
||||||
|
<span className={styles.meta}>本次 {formatUSD(grant.qualifyingUsdMinor)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
label: "来源",
|
||||||
|
width: "minmax(190px, 0.85fr)",
|
||||||
|
render: (grant) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{grant.rechargeType || "-"}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{grant.rechargeCoinAmount ? `${formatNumber(grant.rechargeCoinAmount)} 金币` : "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(120px, 0.55fr)",
|
||||||
|
render: (grant) => grantStatusLabel(grant.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
label: "创建时间",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (grant) => formatMillis(grant.createdAtMs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grantId",
|
||||||
|
label: "记录",
|
||||||
|
width: "minmax(280px, 1fr)",
|
||||||
|
render: (grant) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{grant.grantId}</span>
|
||||||
|
<span className={styles.meta}>{grant.transactionId || grant.eventId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(180px, 0.85fr)",
|
||||||
|
render: (grant) => grant.failureReason || "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CumulativeRechargeRewardPage() {
|
||||||
|
const page = useCumulativeRechargeRewardPage();
|
||||||
|
const total = page.grants.total || 0;
|
||||||
|
const columns = columnsBase.map((column) => {
|
||||||
|
if (column.key === "user") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索用户 ID、短号、名称",
|
||||||
|
value: page.query,
|
||||||
|
onChange: page.changeQuery,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "cycle") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "周期,如 2026-W23",
|
||||||
|
value: page.cycleKey,
|
||||||
|
onChange: page.changeCycleKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: [["", "全部状态"], ...grantStatusOptions],
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<CumulativeRechargeRewardConfigSummary
|
||||||
|
canUpdate={page.abilities.canUpdate}
|
||||||
|
config={page.config}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
onEdit={page.openConfigDrawer}
|
||||||
|
onRefresh={page.reloadConfig}
|
||||||
|
/>
|
||||||
|
<DataState error={page.grantsError} loading={page.grantsLoading} onRetry={page.reloadGrants}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
items={page.grants.items || []}
|
||||||
|
minWidth="1500px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.grants.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(grant) => grant.grantId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<CumulativeRechargeRewardConfigDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
configSaving={page.configSaving}
|
||||||
|
form={page.form}
|
||||||
|
open={page.configDrawerOpen}
|
||||||
|
resourceGroups={page.resourceGroups}
|
||||||
|
setForm={page.setForm}
|
||||||
|
onClose={page.closeConfigDrawer}
|
||||||
|
onSubmit={page.submitConfig}
|
||||||
|
/>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrantUser({ grant }) {
|
||||||
|
const user = grant.user || {};
|
||||||
|
return (
|
||||||
|
<div className={styles.identity}>
|
||||||
|
<Avatar
|
||||||
|
alt={user.username || String(grant.userId)}
|
||||||
|
src={user.avatar || ""}
|
||||||
|
sx={{ width: 36, height: 36 }}
|
||||||
|
/>
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{user.username || `用户 ${grant.userId}`}</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantStatusLabel(status) {
|
||||||
|
return grantStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUSD(value) {
|
||||||
|
return `$${(Number(value || 0) / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
11
src/features/cumulative-recharge-reward/permissions.js
Normal file
11
src/features/cumulative-recharge-reward/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useCumulativeRechargeRewardAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpdate: can(PERMISSIONS.cumulativeRechargeRewardUpdate),
|
||||||
|
canView: can(PERMISSIONS.cumulativeRechargeRewardView),
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/features/cumulative-recharge-reward/routes.js
Normal file
13
src/features/cumulative-recharge-reward/routes.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const cumulativeRechargeRewardRoutes = [
|
||||||
|
{
|
||||||
|
label: "累充奖励配置",
|
||||||
|
loader: () =>
|
||||||
|
import("./pages/CumulativeRechargeRewardPage.jsx").then((module) => module.CumulativeRechargeRewardPage),
|
||||||
|
menuCode: MENU_CODES.cumulativeRechargeReward,
|
||||||
|
pageKey: "cumulative-recharge-reward",
|
||||||
|
path: "/activities/cumulative-recharge-reward",
|
||||||
|
permission: PERMISSIONS.cumulativeRechargeRewardView,
|
||||||
|
},
|
||||||
|
];
|
||||||
46
src/features/dashboard/components/DashboardFrequentMenus.jsx
Normal file
46
src/features/dashboard/components/DashboardFrequentMenus.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { getFrequentSecondLevelMenus } from "@/app/navigation/menuUsage.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
export function DashboardFrequentMenus({ menus = [] }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const frequentMenus = useMemo(() => getFrequentSecondLevelMenus({ menus, user }), [menus, user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="frequent-menu-section" aria-label="常用二级菜单">
|
||||||
|
<div className="frequent-menu-head">
|
||||||
|
<div>
|
||||||
|
<h2>常用页面</h2>
|
||||||
|
<p>本地缓存的二级菜单访问次数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{frequentMenus.length ? (
|
||||||
|
<div className="frequent-menu-grid">
|
||||||
|
{frequentMenus.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="frequent-menu-card"
|
||||||
|
key={item.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
>
|
||||||
|
<span className="frequent-menu-card__icon">{Icon ? <Icon fontSize="small" /> : null}</span>
|
||||||
|
<span className="frequent-menu-card__group">{item.parentLabel}</span>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<span className="frequent-menu-card__meta">
|
||||||
|
进入 {item.count || 0} 次 · {formatMillis(item.lastVisitedAtMs)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="frequent-menu-empty">暂无本地访问记录</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,105 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frequent-menu-section {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size-lg);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-head p {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card {
|
||||||
|
display: grid;
|
||||||
|
min-height: 112px;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-base) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-base) var(--ease-standard),
|
||||||
|
transform var(--motion-base) var(--ease-emphasized);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card:hover {
|
||||||
|
border-color: var(--primary-border-strong);
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card__group {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-card__meta {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequent-menu-empty {
|
||||||
|
display: flex;
|
||||||
|
min-height: 88px;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
min-height: 304px;
|
min-height: 304px;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
|
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
|
||||||
|
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
|
||||||
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
|
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
|
||||||
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
|
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
|
||||||
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
|
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const page = useDashboardPage();
|
const page = useDashboardPage();
|
||||||
|
const { menus = [] } = useOutletContext() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -18,6 +21,7 @@ export function OverviewPage() {
|
|||||||
|
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<DashboardStats overview={page.overview} stats={page.stats} />
|
<DashboardStats overview={page.overview} stats={page.stats} />
|
||||||
|
<DashboardFrequentMenus menus={menus} />
|
||||||
<DashboardCharts overview={page.overview} />
|
<DashboardCharts overview={page.overview} />
|
||||||
</DataState>
|
</DataState>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export interface GamePlatformDto {
|
|||||||
platformName: string;
|
platformName: string;
|
||||||
status: string;
|
status: string;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
|
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||||
callbackSecret?: string;
|
callbackSecret?: string;
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const defaultPlatformForm = {
|
|||||||
platformName: "",
|
platformName: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
|
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||||
adapterType: "demo",
|
adapterType: "demo",
|
||||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||||
callbackSecret: "",
|
callbackSecret: "",
|
||||||
|
|||||||
@ -49,6 +49,7 @@ const adapterTypeOptions = [
|
|||||||
["leadercc_v1", "灵仙 LeaderCC V1"],
|
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||||
["zeeone_v1", "ZeeOne V1"],
|
["zeeone_v1", "ZeeOne V1"],
|
||||||
["baishun_v1", "百顺 BAISHUN V1"],
|
["baishun_v1", "百顺 BAISHUN V1"],
|
||||||
|
["vivagames_v1", "VIVAGAMES V1"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
|
|||||||
@ -36,7 +36,14 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
|
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
|
||||||
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
||||||
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({ page: 1, page_size: 10, region_id: 7, status: "active" });
|
await listCoinSellers({
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
region_id: 7,
|
||||||
|
sort_by: "merchant_balance",
|
||||||
|
sort_direction: "desc",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
await createBDLeader({
|
await createBDLeader({
|
||||||
commandId: "bd-leader-test",
|
commandId: "bd-leader-test",
|
||||||
positionAlias: "Senior Admin",
|
positionAlias: "Senior Admin",
|
||||||
@ -80,6 +87,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(String(hostUrl)).toContain("sort_direction=desc");
|
expect(String(hostUrl)).toContain("sort_direction=desc");
|
||||||
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
|
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
|
||||||
expect(String(coinSellerUrl)).toContain("region_id=7");
|
expect(String(coinSellerUrl)).toContain("region_id=7");
|
||||||
|
expect(String(coinSellerUrl)).toContain("sort_by=merchant_balance");
|
||||||
|
expect(String(coinSellerUrl)).toContain("sort_direction=desc");
|
||||||
expect(String(coinSellerUrl)).toContain("status=active");
|
expect(String(coinSellerUrl)).toContain("status=active");
|
||||||
expect(coinSellerInit?.method).toBe("GET");
|
expect(coinSellerInit?.method).toBe("GET");
|
||||||
expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
|
expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
|
||||||
|
|||||||
@ -50,6 +50,8 @@ export function useHostCoinSellersPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState("");
|
||||||
|
const [sortDirection, setSortDirection] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [activeAction, setActiveAction] = useState("");
|
const [activeAction, setActiveAction] = useState("");
|
||||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||||
@ -60,16 +62,20 @@ export function useHostCoinSellersPage() {
|
|||||||
const [loadingRates, setLoadingRates] = useState(false);
|
const [loadingRates, setLoadingRates] = useState(false);
|
||||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const [contactSavingIds, setContactSavingIds] = useState({});
|
||||||
|
const [sellerRowPatches, setSellerRowPatches] = useState({});
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
region_id: regionId,
|
region_id: regionId,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_direction: sortDirection,
|
||||||
status,
|
status,
|
||||||
}),
|
}),
|
||||||
[query, regionId, status],
|
[query, regionId, sortBy, sortDirection, status],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data: queryData = emptyData,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
reload,
|
reload,
|
||||||
@ -81,6 +87,16 @@ export function useHostCoinSellersPage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
queryKey: ["host-org", "coin-sellers", filters, page],
|
queryKey: ["host-org", "coin-sellers", filters, page],
|
||||||
});
|
});
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const sourceItems = Array.isArray(queryData.items) ? queryData.items : [];
|
||||||
|
return {
|
||||||
|
...queryData,
|
||||||
|
items: sourceItems.map((seller) => ({
|
||||||
|
...seller,
|
||||||
|
...(sellerRowPatches[seller.userId] || {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [queryData, sellerRowPatches]);
|
||||||
|
|
||||||
const changeQuery = (value) => {
|
const changeQuery = (value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
@ -97,10 +113,18 @@ export function useHostCoinSellersPage() {
|
|||||||
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("");
|
||||||
|
setSortBy("");
|
||||||
|
setSortDirection("");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -232,6 +256,44 @@ export function useHostCoinSellersPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSellerContact = async (seller, rawContact) => {
|
||||||
|
if (!abilities.canUpdate || !seller?.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const contact = String(rawContact ?? "").trim();
|
||||||
|
if (contact === String(seller.contact || "").trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sellerID = seller.userId;
|
||||||
|
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
|
||||||
|
try {
|
||||||
|
await setCoinSellerStatus(sellerID, {
|
||||||
|
commandId: makeCommandId("coin-seller-contact"),
|
||||||
|
contact,
|
||||||
|
reason: "update coin seller contact",
|
||||||
|
status: seller.status || "active",
|
||||||
|
});
|
||||||
|
setSellerRowPatches((current) => ({
|
||||||
|
...current,
|
||||||
|
[sellerID]: {
|
||||||
|
...(current[sellerID] || {}),
|
||||||
|
contact,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
showToast("联系方式已保存", "success");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存联系方式失败", "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setContactSavingIds((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[sellerID];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitStockCredit = async (event) => {
|
const submitStockCredit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selectedSeller?.userId) {
|
if (!selectedSeller?.userId) {
|
||||||
@ -304,8 +366,10 @@ export function useHostCoinSellersPage() {
|
|||||||
activeAction,
|
activeAction,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeRegionId,
|
changeRegionId,
|
||||||
|
changeSort,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
closeAction,
|
closeAction,
|
||||||
|
contactSavingIds,
|
||||||
createForm,
|
createForm,
|
||||||
data,
|
data,
|
||||||
editForm,
|
editForm,
|
||||||
@ -330,6 +394,7 @@ export function useHostCoinSellersPage() {
|
|||||||
changeRateRegionId,
|
changeRateRegionId,
|
||||||
removeRateTier,
|
removeRateTier,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
saveSellerContact,
|
||||||
selectedSeller,
|
selectedSeller,
|
||||||
setCreateForm,
|
setCreateForm,
|
||||||
setEditForm,
|
setEditForm,
|
||||||
@ -337,6 +402,8 @@ export function useHostCoinSellersPage() {
|
|||||||
setStockForm,
|
setStockForm,
|
||||||
status,
|
status,
|
||||||
stockForm,
|
stockForm,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
submitRateSettings,
|
submitRateSettings,
|
||||||
submitCreateSeller,
|
submitCreateSeller,
|
||||||
submitEditSeller,
|
submitEditSeller,
|
||||||
|
|||||||
@ -110,6 +110,15 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ledgerDrawer.ledgerDrawer {
|
||||||
|
width: min(960px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledgerDrawerBody.ledgerDrawerBody {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.rateTierHeader {
|
.rateTierHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||||
|
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
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 Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
@ -16,6 +19,7 @@ import {
|
|||||||
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 { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
||||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
@ -46,23 +50,21 @@ const baseColumns = [
|
|||||||
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
|
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
|
||||||
width: "minmax(170px, 1fr)",
|
width: "minmax(170px, 1fr)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "merchantAssetType",
|
|
||||||
label: "资产类型",
|
|
||||||
render: (item) => item.merchantAssetType || "-",
|
|
||||||
width: "minmax(150px, 0.9fr)",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "merchantBalance",
|
key: "merchantBalance",
|
||||||
label: "币商余额",
|
label: "币商余额",
|
||||||
|
sortable: true,
|
||||||
|
sortField: "merchant_balance",
|
||||||
render: (item) => formatNumber(item.merchantBalance),
|
render: (item) => formatNumber(item.merchantBalance),
|
||||||
width: "minmax(120px, 0.8fr)",
|
width: "minmax(120px, 0.8fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "createdByUserId",
|
key: "totalRechargeUsdt",
|
||||||
label: "创建人 ID",
|
label: "累充USDT",
|
||||||
render: (item) => item.createdByUserId || "-",
|
sortable: true,
|
||||||
width: "minmax(110px, 0.7fr)",
|
sortField: "total_recharge_usdt",
|
||||||
|
render: (item) => formatUSDTMicro(item.totalRechargeUsdtMicro),
|
||||||
|
width: "minmax(130px, 0.8fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "updatedAtMs",
|
key: "updatedAtMs",
|
||||||
@ -119,6 +121,20 @@ export function HostCoinSellersPage() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (column.sortable) {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
header: (
|
||||||
|
<SortHeader
|
||||||
|
field={column.sortField}
|
||||||
|
label={column.label}
|
||||||
|
sortBy={page.sortBy}
|
||||||
|
sortDirection={page.sortDirection}
|
||||||
|
onToggle={page.changeSort}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
return column;
|
return column;
|
||||||
});
|
});
|
||||||
const regionFilter = createRegionColumnFilter({
|
const regionFilter = createRegionColumnFilter({
|
||||||
@ -152,7 +168,7 @@ export function HostCoinSellersPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
context={{ page, regionOptions: page.regionOptions }}
|
context={{ page, regionOptions: page.regionOptions }}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1040px"
|
minWidth="930px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -204,7 +220,7 @@ export function HostCoinSellersPage() {
|
|||||||
onSubmit={page.submitEditSeller}
|
onSubmit={page.submitEditSeller}
|
||||||
title="编辑联系方式"
|
title="编辑联系方式"
|
||||||
>
|
>
|
||||||
<TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} />
|
<TextField disabled label="用户 ID" required value={page.editForm.targetUserId} />
|
||||||
<TextField
|
<TextField
|
||||||
disabled={updateDisabled}
|
disabled={updateDisabled}
|
||||||
label="联系方式"
|
label="联系方式"
|
||||||
@ -290,6 +306,8 @@ export function HostCoinSellersPage() {
|
|||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<SideDrawer
|
<SideDrawer
|
||||||
|
className={styles.ledgerDrawer}
|
||||||
|
contentClassName={styles.ledgerDrawerBody}
|
||||||
open={page.activeAction === "ledger"}
|
open={page.activeAction === "ledger"}
|
||||||
title={`币商流水 - ${sellerDrawerTitle(page.selectedSeller)}`}
|
title={`币商流水 - ${sellerDrawerTitle(page.selectedSeller)}`}
|
||||||
width="wide"
|
width="wide"
|
||||||
@ -420,14 +438,21 @@ function SellerStatusSwitch({ item, page }) {
|
|||||||
|
|
||||||
function SellerContact({ item, page }) {
|
function SellerContact({ item, page }) {
|
||||||
const contact = String(item.contact || "").trim();
|
const contact = String(item.contact || "").trim();
|
||||||
if (!page?.abilities.canUpdate) {
|
|
||||||
return <span className={contact ? styles.inlineValue : styles.contactPlaceholder}>{contact || "未填写"}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={styles.contactButton} type="button" onClick={() => page.openEditSeller(item)}>
|
<InlineEditableCell
|
||||||
{contact || "填写联系方式"}
|
buttonTitle="点击编辑联系方式"
|
||||||
</button>
|
displayValue={contact || (page?.abilities.canUpdate ? "填写联系方式" : "未填写")}
|
||||||
|
editable={Boolean(page?.abilities.canUpdate)}
|
||||||
|
emptyLabel={page?.abilities.canUpdate ? "填写联系方式" : "未填写"}
|
||||||
|
inputProps={{ maxLength: 128 }}
|
||||||
|
maxWidth="170px"
|
||||||
|
normalizeValue={(value) => String(value ?? "").trim()}
|
||||||
|
saving={Boolean(page?.contactSavingIds?.[item.userId])}
|
||||||
|
value={contact}
|
||||||
|
variant="link"
|
||||||
|
width="100%"
|
||||||
|
onSave={(nextValue) => page.saveSellerContact(item, nextValue)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,6 +499,45 @@ function formatNumber(value) {
|
|||||||
return Number(value || 0).toLocaleString("zh-CN");
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortHeader({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const coinLedgerActionSx = {
|
const coinLedgerActionSx = {
|
||||||
borderColor: "rgba(37, 99, 235, 0.28)",
|
borderColor: "rgba(37, 99, 235, 0.28)",
|
||||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||||
|
|||||||
@ -34,6 +34,48 @@ test("coin seller row action opens seller ledger with current seller", () => {
|
|||||||
expect(mocks.openSellerLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }));
|
expect(mocks.openSellerLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin seller table shows cumulative USDT and hides creator id column", () => {
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("累充USDT")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("12.5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller balance and cumulative USDT headers request sorting", () => {
|
||||||
|
const changeSort = vi.fn();
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("按币商余额降序排序"));
|
||||||
|
fireEvent.click(screen.getByLabelText("按累充USDT降序排序"));
|
||||||
|
|
||||||
|
expect(changeSort).toHaveBeenCalledWith("merchant_balance");
|
||||||
|
expect(changeSort).toHaveBeenCalledWith("total_recharge_usdt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coin seller contact edits inline and saves on blur", () => {
|
||||||
|
const saveSellerContact = vi.fn();
|
||||||
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { ...pageFixture().abilities, canUpdate: true },
|
||||||
|
saveSellerContact,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<HostCoinSellersPage />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "菲律賓 +63" }));
|
||||||
|
const input = screen.getByDisplayValue("菲律賓 +63");
|
||||||
|
fireEvent.change(input, { target: { value: "+63" } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(saveSellerContact).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }), "+63");
|
||||||
|
});
|
||||||
|
|
||||||
test("coin seller ledger drawer passes locked seller id to shared table", () => {
|
test("coin seller ledger drawer passes locked seller id to shared table", () => {
|
||||||
const seller = sellerFixture();
|
const seller = sellerFixture();
|
||||||
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ activeAction: "ledger", selectedSeller: seller }));
|
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ activeAction: "ledger", selectedSeller: seller }));
|
||||||
@ -59,8 +101,10 @@ function pageFixture(patch = {}) {
|
|||||||
changeQuery: vi.fn(),
|
changeQuery: vi.fn(),
|
||||||
changeRateRegionId: vi.fn(),
|
changeRateRegionId: vi.fn(),
|
||||||
changeRegionId: vi.fn(),
|
changeRegionId: vi.fn(),
|
||||||
|
changeSort: vi.fn(),
|
||||||
changeStatus: vi.fn(),
|
changeStatus: vi.fn(),
|
||||||
closeAction: vi.fn(),
|
closeAction: vi.fn(),
|
||||||
|
contactSavingIds: {},
|
||||||
createForm: { contact: "", targetUserId: "" },
|
createForm: { contact: "", targetUserId: "" },
|
||||||
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
|
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
|
||||||
editForm: { contact: "", targetUserId: "" },
|
editForm: { contact: "", targetUserId: "" },
|
||||||
@ -83,6 +127,7 @@ function pageFixture(patch = {}) {
|
|||||||
reload: vi.fn(),
|
reload: vi.fn(),
|
||||||
removeRateTier: vi.fn(),
|
removeRateTier: vi.fn(),
|
||||||
resetFilters: vi.fn(),
|
resetFilters: vi.fn(),
|
||||||
|
saveSellerContact: vi.fn(),
|
||||||
selectedSeller: null,
|
selectedSeller: null,
|
||||||
setCreateForm: vi.fn(),
|
setCreateForm: vi.fn(),
|
||||||
setEditForm: vi.fn(),
|
setEditForm: vi.fn(),
|
||||||
@ -90,6 +135,8 @@ function pageFixture(patch = {}) {
|
|||||||
setStockForm: vi.fn(),
|
setStockForm: vi.fn(),
|
||||||
status: "",
|
status: "",
|
||||||
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
||||||
|
sortBy: "",
|
||||||
|
sortDirection: "",
|
||||||
submitCreateSeller: vi.fn(),
|
submitCreateSeller: vi.fn(),
|
||||||
submitEditSeller: vi.fn(),
|
submitEditSeller: vi.fn(),
|
||||||
submitRateSettings: vi.fn(),
|
submitRateSettings: vi.fn(),
|
||||||
@ -109,6 +156,7 @@ function sellerFixture() {
|
|||||||
merchantBalance: 9400000,
|
merchantBalance: 9400000,
|
||||||
regionId: 1,
|
regionId: 1,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
totalRechargeUsdtMicro: 12500000,
|
||||||
updatedAtMs: 1760000000000,
|
updatedAtMs: 1760000000000,
|
||||||
userId: "3001",
|
userId: "3001",
|
||||||
username: "Flower92",
|
username: "Flower92",
|
||||||
|
|||||||
42
src/features/host-org/schema.test.ts
Normal file
42
src/features/host-org/schema.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema } from "./schema";
|
||||||
|
|
||||||
|
test("coin seller contact accepts plus sign with large internal user id", () => {
|
||||||
|
const result = coinSellerStatusSchema.safeParse({
|
||||||
|
commandId: "coin-seller-edit-test",
|
||||||
|
contact: "+63",
|
||||||
|
reason: "update contact",
|
||||||
|
status: "active",
|
||||||
|
targetUserId: "320756743338463232",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.contact).toBe("+63");
|
||||||
|
expect(result.data.targetUserId).toBe("320756743338463232");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agency action schemas preserve large agency ids as strings", () => {
|
||||||
|
const agencyId = "321170072154411009";
|
||||||
|
const closeResult = agencyCloseSchema.safeParse({
|
||||||
|
agencyId,
|
||||||
|
commandId: "agency-close-test",
|
||||||
|
reason: "close agency",
|
||||||
|
});
|
||||||
|
const joinResult = agencyJoinEnabledSchema.safeParse({
|
||||||
|
agencyId,
|
||||||
|
commandId: "agency-join-test",
|
||||||
|
joinEnabled: false,
|
||||||
|
reason: "disable join",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(closeResult.success).toBe(true);
|
||||||
|
expect(joinResult.success).toBe(true);
|
||||||
|
if (closeResult.success) {
|
||||||
|
expect(closeResult.data.agencyId).toBe(agencyId);
|
||||||
|
}
|
||||||
|
if (joinResult.success) {
|
||||||
|
expect(joinResult.data.agencyId).toBe(agencyId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -12,6 +12,15 @@ const commandContactSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
|
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
|
||||||
|
const entityIdSchema = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
},
|
||||||
|
z.string().min(1, "请输入有效用户 ID").regex(/^\d+$/, "请输入有效用户 ID"),
|
||||||
|
);
|
||||||
const optionalUserIdSchema = z.preprocess(
|
const optionalUserIdSchema = z.preprocess(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@ -90,7 +99,7 @@ export const createCoinSellerSchema = commandContactSchema.extend({
|
|||||||
|
|
||||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
targetUserId: userIdSchema,
|
targetUserId: entityIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const coinSellerStockCreditSchema = z
|
export const coinSellerStockCreditSchema = z
|
||||||
@ -121,11 +130,11 @@ export const createAgencySchema = commandBaseSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const agencyCloseSchema = commandBaseSchema.extend({
|
export const agencyCloseSchema = commandBaseSchema.extend({
|
||||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
agencyId: entityIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const agencyJoinEnabledSchema = commandBaseSchema.extend({
|
export const agencyJoinEnabledSchema = commandBaseSchema.extend({
|
||||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
agencyId: entityIdSchema,
|
||||||
joinEnabled: z.boolean().default(true),
|
joinEnabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
createCoinAdjustment,
|
createCoinAdjustment,
|
||||||
|
exportCoinSellerLedger,
|
||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
listCoinAdjustments,
|
listCoinAdjustments,
|
||||||
listCoinSellerLedger,
|
listCoinSellerLedger,
|
||||||
@ -93,3 +94,24 @@ test("coin seller ledger API uses generated admin path and filters", async () =>
|
|||||||
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
|
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
|
||||||
expect(listInit?.method).toBe("GET");
|
expect(listInit?.method).toBe("GET");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coin seller ledger export API sends current filters without pagination", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("seller,amount\n", { status: 200 })));
|
||||||
|
|
||||||
|
await exportCoinSellerLedger({
|
||||||
|
end_at_ms: "2000",
|
||||||
|
ledger_type: "admin_stock_credit",
|
||||||
|
seller_keyword: "HUNTER",
|
||||||
|
start_at_ms: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [exportUrl, exportInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(exportUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger/export?");
|
||||||
|
expect(String(exportUrl)).toContain("seller_keyword=HUNTER");
|
||||||
|
expect(String(exportUrl)).toContain("ledger_type=admin_stock_credit");
|
||||||
|
expect(String(exportUrl)).toContain("start_at_ms=1000");
|
||||||
|
expect(String(exportUrl)).toContain("end_at_ms=2000");
|
||||||
|
expect(String(exportUrl)).not.toContain("page=");
|
||||||
|
expect(exportInit?.method).toBe("GET");
|
||||||
|
});
|
||||||
|
|||||||
@ -42,6 +42,14 @@ export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<Coi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportCoinSellerLedger(query: PageQuery = {}): Promise<Response> {
|
||||||
|
return apiRequest("/v1/admin/operations/coin-seller-ledger/export", {
|
||||||
|
method: "GET",
|
||||||
|
query,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
|
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listCoinAdjustments;
|
const endpoint = API_ENDPOINTS.listCoinAdjustments;
|
||||||
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {
|
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
|
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { listCoinSellerLedger } from "@/features/operations/api";
|
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
|
||||||
import styles from "@/features/operations/operations.module.css";
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
import { downloadCsv } from "@/shared/api/download";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import {
|
import {
|
||||||
AdminFilterResetButton,
|
AdminFilterResetButton,
|
||||||
AdminListBody,
|
AdminListBody,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
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 { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -60,6 +64,12 @@ const baseColumns = [
|
|||||||
render: (entry) => <UserCell user={entry.receiver} />,
|
render: (entry) => <UserCell user={entry.receiver} />,
|
||||||
width: "minmax(240px, 1.2fr)",
|
width: "minmax(240px, 1.2fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "operator",
|
||||||
|
label: "操作人",
|
||||||
|
render: (entry) => <OperatorCell entry={entry} />,
|
||||||
|
width: "minmax(130px, 0.65fr)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "sellerBalanceAfter",
|
key: "sellerBalanceAfter",
|
||||||
label: "币商余额",
|
label: "币商余额",
|
||||||
@ -77,9 +87,11 @@ const baseColumns = [
|
|||||||
export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }) {
|
export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }) {
|
||||||
const fixedSellerUserId = String(sellerUserId || lockedSeller?.userId || "").trim();
|
const fixedSellerUserId = String(sellerUserId || lockedSeller?.userId || "").trim();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [sellerKeyword, setSellerKeyword] = useState("");
|
const [sellerKeyword, setSellerKeyword] = useState("");
|
||||||
const [ledgerType, setLedgerType] = useState("");
|
const [ledgerType, setLedgerType] = useState("");
|
||||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -102,6 +114,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const total = data.total || 0;
|
const total = data.total || 0;
|
||||||
|
const hasActiveFilters = Boolean(sellerKeyword || ledgerType || timeRange.startMs || timeRange.endMs);
|
||||||
|
|
||||||
const changeSellerKeyword = (value) => {
|
const changeSellerKeyword = (value) => {
|
||||||
setSellerKeyword(value);
|
setSellerKeyword(value);
|
||||||
@ -121,44 +134,65 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
setTimeRange({ endMs: "", startMs: "" });
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
const downloadLedger = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await downloadCsv(() => exportCoinSellerLedger(filters), "hyapp-coin-seller-ledger.csv");
|
||||||
|
showToast("币商流水已导出", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err?.message || "导出币商流水失败", "error");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const tableColumns = baseColumns.map((column) => {
|
const tableColumns = baseColumns
|
||||||
if (column.key === "seller" && !fixedSellerUserId) {
|
.filter((column) => !(column.key === "seller" && fixedSellerUserId))
|
||||||
|
.map((column) => {
|
||||||
|
if (column.key === "seller" && !fixedSellerUserId) {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "长 ID / 短 ID / 名称",
|
||||||
|
value: sellerKeyword,
|
||||||
|
onChange: changeSellerKeyword,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "ledgerType") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部",
|
||||||
|
options: ledgerTypeOptions,
|
||||||
|
placeholder: "流水类型",
|
||||||
|
value: ledgerType,
|
||||||
|
onChange: changeLedgerType,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
filter: createTextColumnFilter({
|
|
||||||
placeholder: "长 ID / 短 ID / 名称",
|
|
||||||
value: sellerKeyword,
|
|
||||||
onChange: changeSellerKeyword,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
if (column.key === "ledgerType") {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
filter: createOptionsColumnFilter({
|
|
||||||
options: ledgerTypeOptions,
|
|
||||||
placeholder: "流水类型",
|
|
||||||
value: ledgerType,
|
|
||||||
onChange: changeLedgerType,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.ledgerTableShell}>
|
||||||
<AdminListToolbar
|
<AdminListToolbar
|
||||||
filters={
|
filters={
|
||||||
<>
|
<>
|
||||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||||
<AdminFilterResetButton
|
<AdminFilterResetButton
|
||||||
disabled={!sellerKeyword && !ledgerType && !timeRange.startMs && !timeRange.endMs}
|
disabled={!hasActiveFilters}
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
actions={
|
||||||
|
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
|
||||||
|
{exporting ? "导出中" : "导出"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
@ -166,7 +200,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
emptyLabel="当前无币商流水"
|
emptyLabel="当前无币商流水"
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1030px"
|
minWidth={fixedSellerUserId ? "890px" : "1160px"}
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -181,7 +215,22 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
|||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
</>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorCell({ entry }) {
|
||||||
|
if (entry.ledgerType !== "admin_stock_credit") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const operator = entry.operator || {};
|
||||||
|
const operatorId = String(operator.adminId || entry.operatorUserId || "").trim();
|
||||||
|
const name = operator.username || operator.name || operatorId || "-";
|
||||||
|
return (
|
||||||
|
<span className={styles.identityText}>
|
||||||
|
<span className={styles.primaryText}>{name}</span>
|
||||||
|
{operatorId && operatorId !== name ? <span className={styles.meta}>{operatorId}</span> : null}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,26 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ledgerTableShell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledgerTableShell :global(.data-state) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledgerTableShell :global(.table-frame) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledgerTableShell :global(.table-scroll) {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.adjustmentAmount {
|
.adjustmentAmount {
|
||||||
font-size: calc(var(--admin-font-size) + 4px);
|
font-size: calc(var(--admin-font-size) + 4px);
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/features/registration-reward/api.test.ts
Normal file
60
src/features/registration-reward/api.test.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
|
import { listRegistrationRewardClaims } from "./api";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAccessToken("");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listRegistrationRewardClaims sends receive time filters and normalizes today count", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
claim_id: "rclaim_1",
|
||||||
|
coin_amount: 1000,
|
||||||
|
user: {
|
||||||
|
display_user_id: "165121",
|
||||||
|
username: "jayCiE",
|
||||||
|
},
|
||||||
|
user_id: 3201,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
today_claimed_count: 7,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await listRegistrationRewardClaims({
|
||||||
|
claimed_end_ms: 2000,
|
||||||
|
claimed_start_ms: 1000,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
status: "granted",
|
||||||
|
});
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/activity/registration-reward/claims?");
|
||||||
|
expect(String(url)).toContain("claimed_start_ms=1000");
|
||||||
|
expect(String(url)).toContain("claimed_end_ms=2000");
|
||||||
|
expect(String(url)).toContain("status=granted");
|
||||||
|
expect(init?.method).toBe("GET");
|
||||||
|
expect(result.todayClaimedCount).toBe(7);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
claimId: "rclaim_1",
|
||||||
|
coinAmount: 1000,
|
||||||
|
user: { displayUserId: "165121", username: "jayCiE" },
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -48,8 +48,13 @@ export interface RegistrationRewardClaimDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegistrationRewardClaimPageDto extends ApiPage<RegistrationRewardClaimDto> {
|
||||||
|
todayClaimedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
type RawConfig = RegistrationRewardConfigDto & Record<string, unknown>;
|
type RawConfig = RegistrationRewardConfigDto & Record<string, unknown>;
|
||||||
type RawClaim = RegistrationRewardClaimDto & Record<string, unknown>;
|
type RawClaim = RegistrationRewardClaimDto & Record<string, unknown>;
|
||||||
|
type RawClaimPage = ApiPage<RawClaim> & Record<string, unknown>;
|
||||||
|
|
||||||
export function getRegistrationRewardConfig(): Promise<RegistrationRewardConfigDto> {
|
export function getRegistrationRewardConfig(): Promise<RegistrationRewardConfigDto> {
|
||||||
const endpoint = API_ENDPOINTS.getRegistrationRewardConfig;
|
const endpoint = API_ENDPOINTS.getRegistrationRewardConfig;
|
||||||
@ -71,14 +76,15 @@ export function updateRegistrationRewardConfig(
|
|||||||
).then(normalizeConfig);
|
).then(normalizeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<ApiPage<RegistrationRewardClaimDto>> {
|
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<RegistrationRewardClaimPageDto> {
|
||||||
const endpoint = API_ENDPOINTS.listRegistrationRewardClaims;
|
const endpoint = API_ENDPOINTS.listRegistrationRewardClaims;
|
||||||
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
|
return apiRequest<RawClaimPage>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
query,
|
query,
|
||||||
}).then((page) => ({
|
}).then((page) => ({
|
||||||
...page,
|
...page,
|
||||||
items: (page.items || []).map(normalizeClaim),
|
items: (page.items || []).map(normalizeClaim),
|
||||||
|
todayClaimedCount: numberValue(page.todayClaimedCount ?? page.today_claimed_count),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
|
|||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import styles from "@/features/registration-reward/registration-reward.module.css";
|
import styles from "@/features/registration-reward/registration-reward.module.css";
|
||||||
|
|
||||||
export function RegistrationRewardConfigSummary({
|
export function RegistrationRewardConfigSummary({
|
||||||
@ -10,7 +11,10 @@ export function RegistrationRewardConfigSummary({
|
|||||||
configLoading,
|
configLoading,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onTimeRangeChange,
|
||||||
resourceGroups,
|
resourceGroups,
|
||||||
|
timeRange,
|
||||||
|
todayClaimedCount,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={styles.summaryPanel}>
|
<section className={styles.summaryPanel}>
|
||||||
@ -21,10 +25,12 @@ export function RegistrationRewardConfigSummary({
|
|||||||
<SummaryItem label="发放状态">
|
<SummaryItem label="发放状态">
|
||||||
<ConfigStatus config={config} loading={configLoading} />
|
<ConfigStatus config={config} loading={configLoading} />
|
||||||
</SummaryItem>
|
</SummaryItem>
|
||||||
|
<SummaryItem label="今日已领">{formatNumber(todayClaimedCount)} 份</SummaryItem>
|
||||||
<SummaryItem label="每日限制">{config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"}</SummaryItem>
|
<SummaryItem label="每日限制">{config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||||
<SummaryItem label="奖励">{config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}</SummaryItem>
|
<SummaryItem label="奖励">{config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
|
<TimeRangeFilter label="领取时间" value={timeRange} onChange={onTimeRangeChange} />
|
||||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||||
<RefreshOutlined fontSize="small" />
|
<RefreshOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import {
|
|||||||
import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js";
|
import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
const emptyClaims = { items: [], page: 1, pageSize, todayClaimedCount: 0, total: 0 };
|
||||||
|
const emptyTimeRange = { endMs: "", startMs: "" };
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
@ -31,8 +32,17 @@ export function useRegistrationRewardPage() {
|
|||||||
const [resourceGroups, setResourceGroups] = useState([]);
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
|
const [timeRange, setTimeRange] = useState(emptyTimeRange);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
claimed_end_ms: timeRange.endMs || undefined,
|
||||||
|
claimed_start_ms: timeRange.startMs || undefined,
|
||||||
|
keyword: query,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[query, status, timeRange],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
data: claims = emptyClaims,
|
data: claims = emptyClaims,
|
||||||
error: claimsError,
|
error: claimsError,
|
||||||
@ -94,9 +104,15 @@ export function useRegistrationRewardPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeTimeRange = (value) => {
|
||||||
|
setTimeRange(value || emptyTimeRange);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
|
setTimeRange(emptyTimeRange);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,6 +140,7 @@ export function useRegistrationRewardPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
changeQuery,
|
changeQuery,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
|
changeTimeRange,
|
||||||
claims,
|
claims,
|
||||||
claimsError,
|
claimsError,
|
||||||
claimsLoading,
|
claimsLoading,
|
||||||
@ -144,6 +161,7 @@ export function useRegistrationRewardPage() {
|
|||||||
setPage,
|
setPage,
|
||||||
status,
|
status,
|
||||||
submitConfig,
|
submitConfig,
|
||||||
|
timeRange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,12 +44,7 @@ const columnsBase = [
|
|||||||
key: "claimId",
|
key: "claimId",
|
||||||
label: "领取记录",
|
label: "领取记录",
|
||||||
width: "minmax(240px, 1fr)",
|
width: "minmax(240px, 1fr)",
|
||||||
render: (claim) => (
|
render: (claim) => claim.claimId || "-",
|
||||||
<div className={styles.stack}>
|
|
||||||
<span>{claim.claimId}</span>
|
|
||||||
<span className={styles.meta}>{claim.rewardDay || "-"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "failure",
|
key: "failure",
|
||||||
@ -94,8 +89,11 @@ export function RegistrationRewardPage() {
|
|||||||
config={page.config}
|
config={page.config}
|
||||||
configLoading={page.configLoading}
|
configLoading={page.configLoading}
|
||||||
resourceGroups={page.resourceGroups}
|
resourceGroups={page.resourceGroups}
|
||||||
|
timeRange={page.timeRange}
|
||||||
|
todayClaimedCount={page.claims.todayClaimedCount || 0}
|
||||||
onEdit={page.openConfigDrawer}
|
onEdit={page.openConfigDrawer}
|
||||||
onRefresh={page.reloadConfig}
|
onRefresh={page.reloadConfig}
|
||||||
|
onTimeRangeChange={page.changeTimeRange}
|
||||||
/>
|
/>
|
||||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
@ -134,12 +132,14 @@ export function RegistrationRewardPage() {
|
|||||||
|
|
||||||
function ClaimUser({ claim }) {
|
function ClaimUser({ claim }) {
|
||||||
const user = claim.user || {};
|
const user = claim.user || {};
|
||||||
|
const name = user.username || `用户 ${claim.userId}`;
|
||||||
|
const displayUserId = user.displayUserId || "";
|
||||||
return (
|
return (
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
|
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
|
<span className={styles.name}>{displayUserId ? `${name} (${displayUserId})` : name}</span>
|
||||||
<span className={styles.meta}>{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}</span>
|
{displayUserId ? null : <span className={styles.meta}>ID {claim.userId}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -157,7 +157,6 @@ function RewardSummary({ claim }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
<span>{formatNumber(claim.coinAmount)} 金币</span>
|
<span>{formatNumber(claim.coinAmount)} 金币</span>
|
||||||
<span className={styles.meta}>注册奖励</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { RegistrationRewardPage } from "./RegistrationRewardPage.jsx";
|
||||||
|
import { useRegistrationRewardPage } from "@/features/registration-reward/hooks/useRegistrationRewardPage.js";
|
||||||
|
|
||||||
|
vi.mock("@/features/registration-reward/hooks/useRegistrationRewardPage.js", () => ({
|
||||||
|
useRegistrationRewardPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registration reward list shows receive filter today count and compact row fields", () => {
|
||||||
|
vi.mocked(useRegistrationRewardPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
|
render(<RegistrationRewardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("今日已领")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7 份")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("jayCiE (165121)")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("短号 165121")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("1,000 金币").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.queryByText(/^注册奖励$/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("rclaim_1780684209628_899e9717839e0109")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^2026-06-05$/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture() {
|
||||||
|
return {
|
||||||
|
abilities: { canUpdate: true, canView: true },
|
||||||
|
changeQuery: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
changeTimeRange: vi.fn(),
|
||||||
|
claims: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
claimId: "rclaim_1780684209628_899e9717839e0109",
|
||||||
|
claimedAtMs: 1780684209000,
|
||||||
|
coinAmount: 1000,
|
||||||
|
createdAtMs: 1780684209000,
|
||||||
|
rewardDay: "2026-06-05",
|
||||||
|
rewardType: "coin",
|
||||||
|
status: "granted",
|
||||||
|
user: {
|
||||||
|
avatar: "",
|
||||||
|
displayUserId: "165121",
|
||||||
|
username: "jayCiE",
|
||||||
|
},
|
||||||
|
userId: 3201,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
todayClaimedCount: 7,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
claimsError: null,
|
||||||
|
claimsLoading: false,
|
||||||
|
closeConfigDrawer: vi.fn(),
|
||||||
|
config: {
|
||||||
|
coinAmount: 1000,
|
||||||
|
dailyLimit: 500,
|
||||||
|
enabled: true,
|
||||||
|
rewardType: "coin",
|
||||||
|
},
|
||||||
|
configDrawerOpen: false,
|
||||||
|
configLoading: false,
|
||||||
|
configSaving: false,
|
||||||
|
form: {},
|
||||||
|
openConfigDrawer: vi.fn(),
|
||||||
|
page: 1,
|
||||||
|
query: "",
|
||||||
|
reloadClaims: vi.fn(),
|
||||||
|
reloadConfig: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
resourceGroups: [],
|
||||||
|
setForm: vi.fn(),
|
||||||
|
setPage: vi.fn(),
|
||||||
|
status: "",
|
||||||
|
submitConfig: vi.fn(),
|
||||||
|
timeRange: { endMs: "", startMs: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ import MenuItem from "@mui/material/MenuItem";
|
|||||||
import Popover from "@mui/material/Popover";
|
import Popover from "@mui/material/Popover";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminFormDialog,
|
AdminFormDialog,
|
||||||
AdminFormFieldGrid,
|
AdminFormFieldGrid,
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
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 { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
||||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||||
import {
|
import {
|
||||||
AdminActionIconButton,
|
AdminActionIconButton,
|
||||||
@ -279,69 +280,18 @@ function GiftStatusSwitch({ gift, page }) {
|
|||||||
function GiftSortOrderCell({ gift, page }) {
|
function GiftSortOrderCell({ gift, page }) {
|
||||||
const originalValue = String(gift.sortOrder ?? 0);
|
const originalValue = String(gift.sortOrder ?? 0);
|
||||||
const saving = Boolean(page.giftSortSavingIds?.[gift.giftId]);
|
const saving = Boolean(page.giftSortSavingIds?.[gift.giftId]);
|
||||||
const editable = page.abilities.canUpdateGift && !saving;
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState(originalValue);
|
|
||||||
const skipBlurRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editing) {
|
|
||||||
setValue(originalValue);
|
|
||||||
}
|
|
||||||
}, [editing, originalValue]);
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
if (skipBlurRef.current) {
|
|
||||||
skipBlurRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextValue = String(value || "0").trim() || "0";
|
|
||||||
setEditing(false);
|
|
||||||
page.saveGiftSortOrder(gift, nextValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!editing) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={styles.inlineSortButton}
|
|
||||||
disabled={!editable}
|
|
||||||
title={editable ? "点击编辑排序" : undefined}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (editable) {
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{originalValue}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<InlineEditableCell
|
||||||
autoFocus
|
buttonTitle="点击编辑排序"
|
||||||
className={styles.inlineSortInput}
|
editable={page.abilities.canUpdateGift}
|
||||||
disabled={saving}
|
|
||||||
inputProps={{ min: 0, step: 1 }}
|
inputProps={{ min: 0, step: 1 }}
|
||||||
size="small"
|
maxWidth="76px"
|
||||||
|
normalizeValue={(value) => String(value || "0").trim() || "0"}
|
||||||
|
saving={saving}
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={originalValue}
|
||||||
onBlur={save}
|
width="76px"
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onSave={(nextValue) => page.saveGiftSortOrder(gift, nextValue)}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.currentTarget.blur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
skipBlurRef.current = true;
|
|
||||||
setValue(originalValue);
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,51 +76,6 @@
|
|||||||
min-height: var(--control-height);
|
min-height: var(--control-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineSortButton {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 76px;
|
|
||||||
height: 30px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 650;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineSortButton:hover {
|
|
||||||
border-color: var(--primary-border);
|
|
||||||
background: var(--primary-surface);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineSortButton:disabled {
|
|
||||||
cursor: default;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineSortButton:disabled:hover {
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineSortInput {
|
|
||||||
width: 76px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineSortInput :global(.MuiInputBase-input) {
|
|
||||||
padding-right: var(--space-2);
|
|
||||||
padding-left: var(--space-2);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datePopover {
|
.datePopover {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export function RoleFormDrawer({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||||
<form className="form-drawer form-drawer--wide" onSubmit={onSubmit}>
|
<form className="form-drawer form-drawer--wide role-permission-drawer" onSubmit={onSubmit}>
|
||||||
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section">
|
||||||
<div className="form-drawer__section-title">基础信息</div>
|
<div className="form-drawer__section-title">基础信息</div>
|
||||||
@ -28,9 +28,9 @@ export function RoleFormDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{abilities.canLoadPermissions ? (
|
{abilities.canLoadPermissions ? (
|
||||||
<section className="form-drawer__section">
|
<section className="form-drawer__section role-permission-scope">
|
||||||
<div className="form-drawer__section-title">权限范围</div>
|
<div className="form-drawer__section-title">权限范围</div>
|
||||||
<div className="permission-menu-list">
|
<div className="permission-menu-list role-permission-menu-list">
|
||||||
{permissionGroups.map((group) => (
|
{permissionGroups.map((group) => (
|
||||||
<PermissionMenuGroup
|
<PermissionMenuGroup
|
||||||
abilities={abilities}
|
abilities={abilities}
|
||||||
|
|||||||
@ -18,22 +18,86 @@ const emptyRole = { code: "", description: "", name: "", permissionIds: [] };
|
|||||||
const emptyData = { permissions: [], roles: [] };
|
const emptyData = { permissions: [], roles: [] };
|
||||||
const permissionGroupDefinitions = [
|
const permissionGroupDefinitions = [
|
||||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||||
{ key: "users", prefixes: ["user"], title: "用户管理" },
|
{ key: "users", prefixes: ["user"], title: "后台用户" },
|
||||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||||
{ key: "app-users", prefixes: ["app-user"], title: "App 用户" },
|
{ key: "app-users", prefixes: ["app-user", "level-config", "region-block"], title: "APP 用户" },
|
||||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
{ key: "rooms", prefixes: ["room", "room-pin", "room-config"], title: "房间管理" },
|
||||||
|
{ key: "app-config", prefixes: ["app-config", "app-version"], title: "APP 配置" },
|
||||||
|
{
|
||||||
|
key: "resources",
|
||||||
|
prefixes: ["resource", "resource-shop", "resource-group", "resource-grant", "gift", "emoji-pack"],
|
||||||
|
title: "资源管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "operations",
|
||||||
|
prefixes: ["coin-ledger", "coin-seller-ledger", "coin-adjustment", "report", "gift-diamond"],
|
||||||
|
title: "运营管理",
|
||||||
|
},
|
||||||
|
{ key: "payment", prefixes: ["payment-bill", "payment-product"], title: "支付管理" },
|
||||||
|
{
|
||||||
|
key: "activities",
|
||||||
|
prefixes: [
|
||||||
|
"achievement",
|
||||||
|
"cumulative-recharge-reward",
|
||||||
|
"daily-task",
|
||||||
|
"first-recharge-reward",
|
||||||
|
"lucky-gift",
|
||||||
|
"red-packet",
|
||||||
|
"registration-reward",
|
||||||
|
"room-rocket",
|
||||||
|
"room-turnover-reward",
|
||||||
|
"seven-day-checkin",
|
||||||
|
"user-leaderboard",
|
||||||
|
"vip-config",
|
||||||
|
"weekly-star",
|
||||||
|
],
|
||||||
|
title: "活动管理",
|
||||||
|
},
|
||||||
|
{ key: "games", prefixes: ["game"], title: "游戏管理" },
|
||||||
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
{ key: "countries", prefixes: ["country"], title: "国家管理" },
|
||||||
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
{ key: "regions", prefixes: ["region"], title: "区域管理" },
|
||||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
{
|
||||||
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
key: "host-org",
|
||||||
{ key: "hosts", prefixes: ["host"], title: "Host 列表" },
|
prefixes: [
|
||||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "Coin Saller列表" },
|
"agency",
|
||||||
|
"bd",
|
||||||
|
"coin-seller",
|
||||||
|
"host",
|
||||||
|
"host-agency-policy",
|
||||||
|
"host-salary-settlement",
|
||||||
|
"team-salary-policy",
|
||||||
|
],
|
||||||
|
title: "团队管理",
|
||||||
|
},
|
||||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||||
];
|
];
|
||||||
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
||||||
const permissionKindOrder = { menu: 1, button: 2, api: 3 };
|
const permissionKindOrder = { menu: 1, button: 2, api: 3 };
|
||||||
|
const permissionActionOrder = {
|
||||||
|
view: 1,
|
||||||
|
create: 2,
|
||||||
|
update: 3,
|
||||||
|
status: 4,
|
||||||
|
publish: 5,
|
||||||
|
grant: 6,
|
||||||
|
permission: 7,
|
||||||
|
"data-scope": 8,
|
||||||
|
sort: 9,
|
||||||
|
visible: 10,
|
||||||
|
"reset-password": 11,
|
||||||
|
password: 12,
|
||||||
|
export: 13,
|
||||||
|
sync: 14,
|
||||||
|
settle: 15,
|
||||||
|
retry: 16,
|
||||||
|
cancel: 17,
|
||||||
|
"stock-credit": 18,
|
||||||
|
"exchange-rate": 19,
|
||||||
|
delete: 20,
|
||||||
|
manage: 21,
|
||||||
|
};
|
||||||
|
|
||||||
export function useRolesPage() {
|
export function useRolesPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@ -244,5 +308,18 @@ function comparePermissions(left, right) {
|
|||||||
if (kindDiff !== 0) {
|
if (kindDiff !== 0) {
|
||||||
return kindDiff;
|
return kindDiff;
|
||||||
}
|
}
|
||||||
|
const prefixDiff = getPermissionPrefix(left.code).localeCompare(getPermissionPrefix(right.code));
|
||||||
|
if (prefixDiff !== 0) {
|
||||||
|
return prefixDiff;
|
||||||
|
}
|
||||||
|
const actionDiff = getPermissionActionOrder(left.code) - getPermissionActionOrder(right.code);
|
||||||
|
if (actionDiff !== 0) {
|
||||||
|
return actionDiff;
|
||||||
|
}
|
||||||
return left.code.localeCompare(right.code);
|
return left.code.localeCompare(right.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPermissionActionOrder(code = "") {
|
||||||
|
const action = code.split(":")[1] || "";
|
||||||
|
return permissionActionOrder[action] || 99;
|
||||||
|
}
|
||||||
|
|||||||
168
src/features/room-turnover-reward/api.ts
Normal file
168
src/features/room-turnover-reward/api.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
import type { ApiPage, PageQuery } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export interface RoomTurnoverRewardTierDto {
|
||||||
|
tierId: number;
|
||||||
|
tierCode: string;
|
||||||
|
tierName: string;
|
||||||
|
thresholdCoinSpent: number;
|
||||||
|
rewardCoinAmount: number;
|
||||||
|
status: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTurnoverRewardConfigDto {
|
||||||
|
appCode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: RoomTurnoverRewardTierDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTurnoverRewardConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
tiers: Array<{
|
||||||
|
tier_id?: number;
|
||||||
|
tier_code: string;
|
||||||
|
tier_name: string;
|
||||||
|
threshold_coin_spent: number;
|
||||||
|
reward_coin_amount: number;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTurnoverRewardSettlementDto {
|
||||||
|
settlementId: string;
|
||||||
|
roomId: string;
|
||||||
|
ownerUserId: number;
|
||||||
|
periodStartMs: number;
|
||||||
|
periodEndMs: number;
|
||||||
|
coinSpent: number;
|
||||||
|
tierId: number;
|
||||||
|
tierCode: string;
|
||||||
|
thresholdCoinSpent: number;
|
||||||
|
rewardCoinAmount: number;
|
||||||
|
status: string;
|
||||||
|
walletCommandId?: string;
|
||||||
|
walletTransactionId?: string;
|
||||||
|
failureReason?: string;
|
||||||
|
settledAtMs?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTurnoverRewardSettlementQuery extends PageQuery {
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawConfig = RoomTurnoverRewardConfigDto & Record<string, unknown>;
|
||||||
|
type RawTier = RoomTurnoverRewardTierDto & Record<string, unknown>;
|
||||||
|
type RawSettlement = RoomTurnoverRewardSettlementDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getRoomTurnoverRewardConfig(): Promise<RoomTurnoverRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getRoomTurnoverRewardConfig;
|
||||||
|
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getRoomTurnoverRewardConfig), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRoomTurnoverRewardConfig(
|
||||||
|
payload: RoomTurnoverRewardConfigPayload,
|
||||||
|
): Promise<RoomTurnoverRewardConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateRoomTurnoverRewardConfig;
|
||||||
|
return apiRequest<RawConfig, RoomTurnoverRewardConfigPayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateRoomTurnoverRewardConfig),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRoomTurnoverRewardSettlements(
|
||||||
|
query: RoomTurnoverRewardSettlementQuery = {},
|
||||||
|
): Promise<ApiPage<RoomTurnoverRewardSettlementDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listRoomTurnoverRewardSettlements;
|
||||||
|
return apiRequest<ApiPage<RawSettlement>>(apiEndpointPath(API_OPERATIONS.listRoomTurnoverRewardSettlements), {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeSettlement),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryRoomTurnoverRewardSettlement(
|
||||||
|
settlementId: string,
|
||||||
|
): Promise<RoomTurnoverRewardSettlementDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.retryRoomTurnoverRewardSettlement;
|
||||||
|
return apiRequest<RawSettlement>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.retryRoomTurnoverRewardSettlement, { settlement_id: settlementId }),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeSettlement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(item: RawConfig): RoomTurnoverRewardConfigDto {
|
||||||
|
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||||
|
return {
|
||||||
|
appCode: stringValue(item.appCode ?? item.app_code),
|
||||||
|
enabled: Boolean(item.enabled),
|
||||||
|
tiers: rawTiers.map(normalizeTier),
|
||||||
|
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTier(item: RawTier): RoomTurnoverRewardTierDto {
|
||||||
|
return {
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
tierName: stringValue(item.tierName ?? item.tier_name),
|
||||||
|
thresholdCoinSpent: numberValue(item.thresholdCoinSpent ?? item.threshold_coin_spent),
|
||||||
|
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||||
|
status: stringValue(item.status) || "active",
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettlement(item: RawSettlement): RoomTurnoverRewardSettlementDto {
|
||||||
|
return {
|
||||||
|
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||||
|
roomId: stringValue(item.roomId ?? item.room_id),
|
||||||
|
ownerUserId: numberValue(item.ownerUserId ?? item.owner_user_id),
|
||||||
|
periodStartMs: numberValue(item.periodStartMs ?? item.period_start_ms),
|
||||||
|
periodEndMs: numberValue(item.periodEndMs ?? item.period_end_ms),
|
||||||
|
coinSpent: numberValue(item.coinSpent ?? item.coin_spent),
|
||||||
|
tierId: numberValue(item.tierId ?? item.tier_id),
|
||||||
|
tierCode: stringValue(item.tierCode ?? item.tier_code),
|
||||||
|
thresholdCoinSpent: numberValue(item.thresholdCoinSpent ?? item.threshold_coin_spent),
|
||||||
|
rewardCoinAmount: numberValue(item.rewardCoinAmount ?? item.reward_coin_amount),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||||
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
|
settledAtMs: numberValue(item.settledAtMs ?? item.settled_at_ms),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import styles from "@/features/room-turnover-reward/room-turnover-reward.module.css";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["active", "启用"],
|
||||||
|
["inactive", "停用"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RoomTurnoverRewardConfigDrawer({
|
||||||
|
abilities,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
setForm,
|
||||||
|
}) {
|
||||||
|
const disabled = !abilities.canUpdate || configLoading || configSaving;
|
||||||
|
|
||||||
|
const addTier = () => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: [
|
||||||
|
...(current.tiers || []),
|
||||||
|
{
|
||||||
|
tierId: 0,
|
||||||
|
tierCode: "",
|
||||||
|
tierName: "",
|
||||||
|
thresholdCoinSpent: "",
|
||||||
|
rewardCoinAmount: "",
|
||||||
|
status: "active",
|
||||||
|
sortOrder: String((current.tiers || []).length),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTier = (index, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTier = (index) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||||
|
<form className="form-drawer" onSubmit={onSubmit}>
|
||||||
|
<h2>房间流水奖励配置</h2>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className="form-drawer__section-title">结算状态</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
checkedLabel="开启"
|
||||||
|
disabled={disabled}
|
||||||
|
label="房间流水奖励状态"
|
||||||
|
uncheckedLabel="关闭"
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="form-drawer__section">
|
||||||
|
<div className={styles.drawerSectionTitle}>
|
||||||
|
<span>奖励档位</span>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<AddOutlined fontSize="small" />}
|
||||||
|
type="button"
|
||||||
|
onClick={addTier}
|
||||||
|
>
|
||||||
|
添加档位
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tierEditorList}>
|
||||||
|
{(form.tiers || []).map((tier, index) => (
|
||||||
|
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||||
|
<div className={styles.tierEditorHeader}>
|
||||||
|
<span>档位 {index + 1}</span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除档位"
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
onClick={() => removeTier(index)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="form-drawer__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位编码"
|
||||||
|
value={tier.tierCode}
|
||||||
|
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
label="档位名称"
|
||||||
|
value={tier.tierName}
|
||||||
|
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
label="流水阈值"
|
||||||
|
type="number"
|
||||||
|
value={tier.thresholdCoinSpent}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateTier(index, { thresholdCoinSpent: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
label="奖励金币"
|
||||||
|
type="number"
|
||||||
|
value={tier.rewardCoinAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateTier(index, { rewardCoinAmount: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
disabled={disabled}
|
||||||
|
label="状态"
|
||||||
|
value={tier.status}
|
||||||
|
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||||
|
>
|
||||||
|
{statusOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
label="排序"
|
||||||
|
type="number"
|
||||||
|
value={tier.sortOrder}
|
||||||
|
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="form-drawer__actions">
|
||||||
|
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={<SaveOutlined fontSize="small" />}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import styles from "@/features/room-turnover-reward/room-turnover-reward.module.css";
|
||||||
|
|
||||||
|
export function RoomTurnoverRewardConfigSummary({ canUpdate, config, configLoading, onEdit, onRefresh }) {
|
||||||
|
const tiers = config?.tiers || [];
|
||||||
|
const activeTiers = tiers.filter((tier) => tier.status !== "inactive");
|
||||||
|
return (
|
||||||
|
<section className={styles.summaryPanel}>
|
||||||
|
<div className={styles.summaryItems}>
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>状态</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
styles.statusBadge,
|
||||||
|
config?.enabled ? styles.statusActive : styles.statusInactive,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{config?.enabled ? "开启" : "关闭"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>启用档位</span>
|
||||||
|
<span className={styles.summaryValue}>{activeTiers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>最高奖励</span>
|
||||||
|
<span className={styles.summaryValue}>{formatNumber(maxReward(activeTiers))} 金币</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>更新时间</span>
|
||||||
|
<span className={styles.summaryValue}>{config?.updatedAtMs ? config.updatedAtMs : "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryActions}>
|
||||||
|
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
|
||||||
|
{configLoading ? <CircularProgress color="inherit" size={16} /> : null}
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canUpdate || configLoading}
|
||||||
|
startIcon={<EditOutlined fontSize="small" />}
|
||||||
|
variant="primary"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
编辑配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxReward(tiers) {
|
||||||
|
return tiers.reduce((max, tier) => Math.max(max, Number(tier.rewardCoinAmount || 0)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
getRoomTurnoverRewardConfig,
|
||||||
|
listRoomTurnoverRewardSettlements,
|
||||||
|
retryRoomTurnoverRewardSettlement,
|
||||||
|
updateRoomTurnoverRewardConfig,
|
||||||
|
} from "@/features/room-turnover-reward/api";
|
||||||
|
import { useRoomTurnoverRewardAbilities } from "@/features/room-turnover-reward/permissions.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
const emptySettlements = { items: [], page: 1, pageSize, total: 0 };
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
enabled: false,
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useRoomTurnoverRewardPage() {
|
||||||
|
const abilities = useRoomTurnoverRewardAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [retryingSettlementId, setRetryingSettlementId] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const filters = useMemo(() => ({ keyword: query, status }), [query, status]);
|
||||||
|
const {
|
||||||
|
data: settlements = emptySettlements,
|
||||||
|
error: settlementsError,
|
||||||
|
loading: settlementsLoading,
|
||||||
|
reload: reloadSettlements,
|
||||||
|
} = usePaginatedQuery({
|
||||||
|
errorMessage: "加载房间流水奖励结算记录失败",
|
||||||
|
fetcher: listRoomTurnoverRewardSettlements,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["room-turnover-reward-settlements", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
setConfigLoading(true);
|
||||||
|
try {
|
||||||
|
const nextConfig = await getRoomTurnoverRewardConfig();
|
||||||
|
setConfig(nextConfig);
|
||||||
|
setForm(formFromConfig(nextConfig));
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载房间流水奖励配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadConfig();
|
||||||
|
}, [reloadConfig]);
|
||||||
|
|
||||||
|
const openConfigDrawer = useCallback(() => {
|
||||||
|
setForm(config ? formFromConfig(config) : emptyForm);
|
||||||
|
setConfigDrawerOpen(true);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const closeConfigDrawer = useCallback(() => {
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
|
setForm(config ? formFromConfig(config) : emptyForm);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const changeQuery = (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStatus = (value) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfig = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await updateRoomTurnoverRewardConfig(payloadFromForm(form));
|
||||||
|
setConfig(saved);
|
||||||
|
setForm(formFromConfig(saved));
|
||||||
|
setConfigDrawerOpen(false);
|
||||||
|
showToast("房间流水奖励配置已保存", "success");
|
||||||
|
await reloadSettlements();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存房间流水奖励配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retrySettlement = async (settlement) => {
|
||||||
|
if (!abilities.canRetry || !settlement?.settlementId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await confirm({
|
||||||
|
title: "重试结算",
|
||||||
|
message: `确认重试房间 ${settlement.roomId || "-"} 的流水奖励结算?`,
|
||||||
|
confirmText: "重试",
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRetryingSettlementId(settlement.settlementId);
|
||||||
|
try {
|
||||||
|
await retryRoomTurnoverRewardSettlement(settlement.settlementId);
|
||||||
|
showToast("房间流水奖励结算已重试", "success");
|
||||||
|
await reloadSettlements();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "重试房间流水奖励结算失败", "error");
|
||||||
|
} finally {
|
||||||
|
setRetryingSettlementId("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
changeQuery,
|
||||||
|
changeStatus,
|
||||||
|
closeConfigDrawer,
|
||||||
|
config,
|
||||||
|
configDrawerOpen,
|
||||||
|
configLoading,
|
||||||
|
configSaving,
|
||||||
|
form,
|
||||||
|
openConfigDrawer,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
reloadConfig,
|
||||||
|
reloadSettlements,
|
||||||
|
retryingSettlementId,
|
||||||
|
retrySettlement,
|
||||||
|
setForm,
|
||||||
|
setPage,
|
||||||
|
settlements,
|
||||||
|
settlementsError,
|
||||||
|
settlementsLoading,
|
||||||
|
status,
|
||||||
|
submitConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromConfig(config) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(config?.enabled),
|
||||||
|
tiers: (config?.tiers || []).map((tier) => ({
|
||||||
|
tierId: tier.tierId || 0,
|
||||||
|
tierCode: tier.tierCode || "",
|
||||||
|
tierName: tier.tierName || "",
|
||||||
|
thresholdCoinSpent: tier.thresholdCoinSpent ? String(tier.thresholdCoinSpent) : "",
|
||||||
|
rewardCoinAmount:
|
||||||
|
tier.rewardCoinAmount === undefined || tier.rewardCoinAmount === null
|
||||||
|
? ""
|
||||||
|
: String(tier.rewardCoinAmount),
|
||||||
|
status: tier.status || "active",
|
||||||
|
sortOrder: String(tier.sortOrder || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
const tiers = (form.tiers || []).map((tier, index) => {
|
||||||
|
const thresholdCoinSpent = Number(tier.thresholdCoinSpent || 0);
|
||||||
|
const rewardCoinAmount = Number(tier.rewardCoinAmount || 0);
|
||||||
|
if (!tier.tierCode.trim()) {
|
||||||
|
throw new Error("档位编码不能为空");
|
||||||
|
}
|
||||||
|
if (!tier.tierName.trim()) {
|
||||||
|
throw new Error("档位名称不能为空");
|
||||||
|
}
|
||||||
|
if (thresholdCoinSpent <= 0) {
|
||||||
|
throw new Error("流水阈值必须大于 0");
|
||||||
|
}
|
||||||
|
if (rewardCoinAmount < 0) {
|
||||||
|
throw new Error("奖励金币不能小于 0");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tier_id: Number(tier.tierId || 0),
|
||||||
|
tier_code: tier.tierCode.trim(),
|
||||||
|
tier_name: tier.tierName.trim(),
|
||||||
|
threshold_coin_spent: thresholdCoinSpent,
|
||||||
|
reward_coin_amount: rewardCoinAmount,
|
||||||
|
status: tier.status === "inactive" ? "inactive" : "active",
|
||||||
|
sort_order: Number(tier.sortOrder || index),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const activeTiers = tiers
|
||||||
|
.filter((tier) => tier.status === "active")
|
||||||
|
.sort((left, right) => left.sort_order - right.sort_order);
|
||||||
|
for (let index = 1; index < activeTiers.length; index += 1) {
|
||||||
|
if (activeTiers[index].threshold_coin_spent <= activeTiers[index - 1].threshold_coin_spent) {
|
||||||
|
throw new Error("启用档位的流水阈值必须递增");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.enabled && activeTiers.length === 0) {
|
||||||
|
throw new Error("开启后至少需要一个启用档位");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
tiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
|
||||||
|
import { RoomTurnoverRewardConfigDrawer } from "@/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx";
|
||||||
|
import { RoomTurnoverRewardConfigSummary } from "@/features/room-turnover-reward/components/RoomTurnoverRewardConfigSummary.jsx";
|
||||||
|
import { useRoomTurnoverRewardPage } from "@/features/room-turnover-reward/hooks/useRoomTurnoverRewardPage.js";
|
||||||
|
import styles from "@/features/room-turnover-reward/room-turnover-reward.module.css";
|
||||||
|
import { AdminActionIconButton, AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const settlementStatusOptions = [
|
||||||
|
["pending", "待发放"],
|
||||||
|
["granted", "已发放"],
|
||||||
|
["failed", "发放失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnsBase = [
|
||||||
|
{
|
||||||
|
key: "room",
|
||||||
|
label: "房间",
|
||||||
|
width: "minmax(220px, 0.9fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span className={styles.name}>{item.roomId || "-"}</span>
|
||||||
|
<span className={styles.meta}>房主 {item.ownerUserId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "period",
|
||||||
|
label: "UTC 周期",
|
||||||
|
width: "minmax(260px, 1.1fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{formatMillis(item.periodStartMs)}</span>
|
||||||
|
<span className={styles.meta}>至 {formatMillis(item.periodEndMs)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "coinSpent",
|
||||||
|
label: "房间流水",
|
||||||
|
width: "minmax(160px, 0.7fr)",
|
||||||
|
render: (item) => `${formatNumber(item.coinSpent)} 金币`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tier",
|
||||||
|
label: "命中档位",
|
||||||
|
width: "minmax(180px, 0.75fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.tierCode || `档位 #${item.tierId || "-"}`}</span>
|
||||||
|
<span className={styles.meta}>阈值 {formatNumber(item.thresholdCoinSpent)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reward",
|
||||||
|
label: "奖励金币",
|
||||||
|
width: "minmax(140px, 0.65fr)",
|
||||||
|
render: (item) => formatNumber(item.rewardCoinAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
render: (item) => settlementStatusBadge(item.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failure",
|
||||||
|
label: "失败原因",
|
||||||
|
width: "minmax(220px, 0.9fr)",
|
||||||
|
render: (item) => item.failureReason || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "record",
|
||||||
|
label: "记录",
|
||||||
|
width: "minmax(280px, 1fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<div className={styles.stack}>
|
||||||
|
<span>{item.settlementId}</span>
|
||||||
|
<span className={styles.meta}>{item.walletTransactionId || item.walletCommandId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "112px",
|
||||||
|
fixed: "right",
|
||||||
|
resizable: false,
|
||||||
|
render: (item, context) =>
|
||||||
|
item.status === "failed" && context.abilities.canRetry ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={context.retryingSettlementId === item.settlementId}
|
||||||
|
label="重试"
|
||||||
|
tooltip="重试发放"
|
||||||
|
onClick={() => context.retrySettlement(item)}
|
||||||
|
>
|
||||||
|
<ReplayOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RoomTurnoverRewardPage() {
|
||||||
|
const page = useRoomTurnoverRewardPage();
|
||||||
|
const total = page.settlements.total || 0;
|
||||||
|
const columns = columnsBase.map((column) => {
|
||||||
|
if (column.key === "room") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索房间 ID",
|
||||||
|
value: page.query,
|
||||||
|
onChange: page.changeQuery,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column.key === "status") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: [["", "全部状态"], ...settlementStatusOptions],
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.changeStatus,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<RoomTurnoverRewardConfigSummary
|
||||||
|
canUpdate={page.abilities.canUpdate}
|
||||||
|
config={page.config}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
onEdit={page.openConfigDrawer}
|
||||||
|
onRefresh={page.reloadConfig}
|
||||||
|
/>
|
||||||
|
<DataState error={page.settlementsError} loading={page.settlementsLoading} onRetry={page.reloadSettlements}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
context={{
|
||||||
|
abilities: page.abilities,
|
||||||
|
retryingSettlementId: page.retryingSettlementId,
|
||||||
|
retrySettlement: page.retrySettlement,
|
||||||
|
}}
|
||||||
|
items={page.settlements.items || []}
|
||||||
|
minWidth="1480px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.settlements.pageSize || 50,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.settlementId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<RoomTurnoverRewardConfigDrawer
|
||||||
|
abilities={page.abilities}
|
||||||
|
configLoading={page.configLoading}
|
||||||
|
configSaving={page.configSaving}
|
||||||
|
form={page.form}
|
||||||
|
open={page.configDrawerOpen}
|
||||||
|
setForm={page.setForm}
|
||||||
|
onClose={page.closeConfigDrawer}
|
||||||
|
onSubmit={page.submitConfig}
|
||||||
|
/>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementStatusBadge(status) {
|
||||||
|
const label = settlementStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
|
const className =
|
||||||
|
status === "granted"
|
||||||
|
? styles.statusActive
|
||||||
|
: status === "failed"
|
||||||
|
? styles.statusFailed
|
||||||
|
: styles.statusInactive;
|
||||||
|
return <span className={[styles.statusBadge, className].join(" ")}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
12
src/features/room-turnover-reward/permissions.js
Normal file
12
src/features/room-turnover-reward/permissions.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useRoomTurnoverRewardAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canRetry: can(PERMISSIONS.roomTurnoverRewardRetry),
|
||||||
|
canUpdate: can(PERMISSIONS.roomTurnoverRewardUpdate),
|
||||||
|
canView: can(PERMISSIONS.roomTurnoverRewardView),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,157 @@
|
|||||||
|
.summaryPanel {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems,
|
||||||
|
.summaryActions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusActive {
|
||||||
|
background: var(--success-surface);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusInactive {
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusFailed {
|
||||||
|
background: var(--danger-surface);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerSectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorList {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditor {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tierEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
min-height: 92px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.summaryPanel {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/features/room-turnover-reward/routes.js
Normal file
12
src/features/room-turnover-reward/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const roomTurnoverRewardRoutes = [
|
||||||
|
{
|
||||||
|
label: "房间流水奖励",
|
||||||
|
loader: () => import("./pages/RoomTurnoverRewardPage.jsx").then((module) => module.RoomTurnoverRewardPage),
|
||||||
|
menuCode: MENU_CODES.roomTurnoverReward,
|
||||||
|
pageKey: "room-turnover-reward",
|
||||||
|
path: "/activities/room-turnover-reward",
|
||||||
|
permission: PERMISSIONS.roomTurnoverRewardView,
|
||||||
|
},
|
||||||
|
];
|
||||||
196
src/features/weekly-star/api.ts
Normal file
196
src/features/weekly-star/api.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { apiRequest, type QueryParams } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
|
||||||
|
export interface WeeklyStarGiftDto {
|
||||||
|
giftId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStarRewardDto {
|
||||||
|
rankNo: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStarCycleDto {
|
||||||
|
cycleId: string;
|
||||||
|
activityCode: string;
|
||||||
|
regionId: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
gifts: WeeklyStarGiftDto[];
|
||||||
|
rewards: WeeklyStarRewardDto[];
|
||||||
|
settledAtMs?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStarCyclePayload {
|
||||||
|
cycle_id?: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
region_id: number;
|
||||||
|
start_ms: number;
|
||||||
|
end_ms: number;
|
||||||
|
gift_ids: string[];
|
||||||
|
top1_resource_group_id: number;
|
||||||
|
top2_resource_group_id: number;
|
||||||
|
top3_resource_group_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStarEntryDto {
|
||||||
|
rankNo: number;
|
||||||
|
userId: number;
|
||||||
|
score: number;
|
||||||
|
firstScoredAtMs: number;
|
||||||
|
lastScoredAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStarSettlementDto {
|
||||||
|
settlementId: string;
|
||||||
|
cycleId: string;
|
||||||
|
rankNo: number;
|
||||||
|
userId: number;
|
||||||
|
score: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
walletCommandId: string;
|
||||||
|
walletGrantId: string;
|
||||||
|
status: string;
|
||||||
|
failureReason: string;
|
||||||
|
attemptCount: number;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Raw = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function listWeeklyStarCycles(query: QueryParams = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listWeeklyStarCycles;
|
||||||
|
return apiRequest<{ items?: Raw[]; total?: number; page?: number; page_size?: number }>(endpoint.path, {
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
}).then((page) => ({
|
||||||
|
...page,
|
||||||
|
items: (page.items || []).map(normalizeCycle),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWeeklyStarCycle(payload: WeeklyStarCyclePayload) {
|
||||||
|
const endpoint = API_ENDPOINTS.createWeeklyStarCycle;
|
||||||
|
return apiRequest<Raw, WeeklyStarCyclePayload>(endpoint.path, {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWeeklyStarCycle(cycleId: string, payload: WeeklyStarCyclePayload) {
|
||||||
|
const endpoint = API_ENDPOINTS.updateWeeklyStarCycle;
|
||||||
|
return apiRequest<Raw, WeeklyStarCyclePayload>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.updateWeeklyStarCycle, { cycle_id: cycleId }),
|
||||||
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWeeklyStarCycleStatus(cycleId: string, status: string) {
|
||||||
|
const endpoint = API_ENDPOINTS.setWeeklyStarCycleStatus;
|
||||||
|
return apiRequest<Raw, { status: string }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.setWeeklyStarCycleStatus, { cycle_id: cycleId }),
|
||||||
|
{
|
||||||
|
body: { status },
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeCycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWeeklyStarLeaderboard(cycleId: string) {
|
||||||
|
const endpoint = API_ENDPOINTS.listWeeklyStarLeaderboard;
|
||||||
|
return apiRequest<{ items?: Raw[]; total?: number }>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.listWeeklyStarLeaderboard, { cycle_id: cycleId }),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
query: { page_size: 100 },
|
||||||
|
},
|
||||||
|
).then((resp) => ({
|
||||||
|
total: numberValue(resp.total),
|
||||||
|
items: (resp.items || []).map(normalizeEntry),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWeeklyStarSettlements(cycleId: string) {
|
||||||
|
const endpoint = API_ENDPOINTS.listWeeklyStarSettlements;
|
||||||
|
return apiRequest<Raw[]>(apiEndpointPath(API_OPERATIONS.listWeeklyStarSettlements, { cycle_id: cycleId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then((items) => (items || []).map(normalizeSettlement));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCycle(item: Raw): WeeklyStarCycleDto {
|
||||||
|
return {
|
||||||
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
|
activityCode: stringValue(item.activityCode ?? item.activity_code),
|
||||||
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
|
title: stringValue(item.title),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
startMs: numberValue(item.startMs ?? item.start_ms),
|
||||||
|
endMs: numberValue(item.endMs ?? item.end_ms),
|
||||||
|
gifts: Array.isArray(item.gifts) ? item.gifts.map((gift) => normalizeGift(gift as Raw)) : [],
|
||||||
|
rewards: Array.isArray(item.rewards) ? item.rewards.map((reward) => normalizeReward(reward as Raw)) : [],
|
||||||
|
settledAtMs: numberValue(item.settledAtMs ?? item.settled_at_ms),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGift(item: Raw): WeeklyStarGiftDto {
|
||||||
|
return {
|
||||||
|
giftId: stringValue(item.giftId ?? item.gift_id),
|
||||||
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReward(item: Raw): WeeklyStarRewardDto {
|
||||||
|
return {
|
||||||
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(item: Raw): WeeklyStarEntryDto {
|
||||||
|
return {
|
||||||
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
|
score: numberValue(item.score),
|
||||||
|
firstScoredAtMs: numberValue(item.firstScoredAtMs ?? item.first_scored_at_ms),
|
||||||
|
lastScoredAtMs: numberValue(item.lastScoredAtMs ?? item.last_scored_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettlement(item: Raw): WeeklyStarSettlementDto {
|
||||||
|
return {
|
||||||
|
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||||
|
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||||
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
|
userId: numberValue(item.userId ?? item.user_id),
|
||||||
|
score: numberValue(item.score),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||||
|
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
failureReason: stringValue(item.failureReason ?? item.failure_reason),
|
||||||
|
attemptCount: numberValue(item.attemptCount ?? item.attempt_count),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
656
src/features/weekly-star/pages/WeeklyStarPage.jsx
Normal file
656
src/features/weekly-star/pages/WeeklyStarPage.jsx
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
|
import BarChartOutlined from "@mui/icons-material/BarChartOutlined";
|
||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import FormControl from "@mui/material/FormControl";
|
||||||
|
import InputLabel from "@mui/material/InputLabel";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Select from "@mui/material/Select";
|
||||||
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
createWeeklyStarCycle,
|
||||||
|
listWeeklyStarCycles,
|
||||||
|
listWeeklyStarLeaderboard,
|
||||||
|
listWeeklyStarSettlements,
|
||||||
|
setWeeklyStarCycleStatus,
|
||||||
|
updateWeeklyStarCycle,
|
||||||
|
} from "@/features/weekly-star/api";
|
||||||
|
import { listGifts, listResourceGroups } from "@/features/resources/api";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["draft", "草稿"],
|
||||||
|
["active", "启用"],
|
||||||
|
["disabled", "停用"],
|
||||||
|
["settling", "结算中"],
|
||||||
|
["settled", "已结算"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
cycleId: "",
|
||||||
|
title: "Weekly Star",
|
||||||
|
regionId: "0",
|
||||||
|
status: "draft",
|
||||||
|
startAt: "",
|
||||||
|
endAt: "",
|
||||||
|
giftIds: ["", "", ""],
|
||||||
|
resourceGroupIds: ["", "", ""],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WeeklyStarPage() {
|
||||||
|
const [cycles, setCycles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [gifts, setGifts] = useState([]);
|
||||||
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [leaderboard, setLeaderboard] = useState({ cycle: null, loading: false, items: [], error: "" });
|
||||||
|
const [settlements, setSettlements] = useState({ cycle: null, loading: false, items: [], error: "" });
|
||||||
|
|
||||||
|
const giftOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
gifts.map((gift) => ({
|
||||||
|
label: gift.name ? `${gift.name} · ${gift.giftId}` : String(gift.giftId || ""),
|
||||||
|
value: String(gift.giftId || ""),
|
||||||
|
})),
|
||||||
|
[gifts],
|
||||||
|
);
|
||||||
|
const resourceGroupOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
resourceGroups.map((group) => ({
|
||||||
|
label: group.name ? `${group.name} · ${group.groupId}` : String(group.groupId || ""),
|
||||||
|
value: String(group.groupId || ""),
|
||||||
|
})),
|
||||||
|
[resourceGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadCycles();
|
||||||
|
loadOptions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function reloadCycles() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
listWeeklyStarCycles({ page_size: 100 })
|
||||||
|
.then((page) => {
|
||||||
|
setCycles(page.items || []);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message || "加载周星配置失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOptions() {
|
||||||
|
Promise.allSettled([
|
||||||
|
listGifts({ status: "active", page_size: 200 }),
|
||||||
|
listResourceGroups({ status: "active", page_size: 200 }),
|
||||||
|
]).then(([giftResult, groupResult]) => {
|
||||||
|
if (giftResult.status === "fulfilled") {
|
||||||
|
setGifts(giftResult.value.items || []);
|
||||||
|
}
|
||||||
|
if (groupResult.status === "fulfilled") {
|
||||||
|
setResourceGroups(groupResult.value.items || []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm({
|
||||||
|
...emptyForm,
|
||||||
|
startAt: formatUTCInput(Date.now()),
|
||||||
|
endAt: formatUTCInput(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cycle) {
|
||||||
|
const rewards = [1, 2, 3].map((rankNo) =>
|
||||||
|
String(cycle.rewards.find((item) => item.rankNo === rankNo)?.resourceGroupId || ""),
|
||||||
|
);
|
||||||
|
setForm({
|
||||||
|
cycleId: cycle.cycleId,
|
||||||
|
title: cycle.title || "Weekly Star",
|
||||||
|
regionId: String(cycle.regionId || 0),
|
||||||
|
status: cycle.status || "draft",
|
||||||
|
startAt: formatUTCInput(cycle.startMs),
|
||||||
|
endAt: formatUTCInput(cycle.endMs),
|
||||||
|
giftIds: [0, 1, 2].map((index) => String(cycle.gifts[index]?.giftId || "")),
|
||||||
|
resourceGroupIds: rewards,
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
if (!saving) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGift(index, value) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
giftIds: current.giftIds.map((item, itemIndex) => (itemIndex === index ? value : item)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReward(index, value) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
resourceGroupIds: current.resourceGroupIds.map((item, itemIndex) => (itemIndex === index ? value : item)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = buildPayload(form);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form.cycleId) {
|
||||||
|
await updateWeeklyStarCycle(form.cycleId, payload);
|
||||||
|
setToast("周星配置已更新");
|
||||||
|
} else {
|
||||||
|
await createWeeklyStarCycle(payload);
|
||||||
|
setToast("周星配置已创建");
|
||||||
|
}
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reloadCycles();
|
||||||
|
} catch (err) {
|
||||||
|
setToast(err.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(values) {
|
||||||
|
const giftIds = values.giftIds.map((item) => String(item || "").trim()).filter(Boolean);
|
||||||
|
const resourceGroupIds = values.resourceGroupIds.map((item) => Number(item || 0));
|
||||||
|
const startMs = parseUTCInput(values.startAt);
|
||||||
|
const endMs = parseUTCInput(values.endAt);
|
||||||
|
if (!values.title.trim()) {
|
||||||
|
setToast("请填写活动标题");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (giftIds.length !== 3 || new Set(giftIds).size !== 3) {
|
||||||
|
setToast("请选择 3 个不同礼物");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (resourceGroupIds.some((item) => !Number.isFinite(item) || item <= 0)) {
|
||||||
|
setToast("请填写 Top1/Top2/Top3 资源组 ID");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!startMs || !endMs || endMs <= startMs) {
|
||||||
|
setToast("请填写有效 UTC 时间范围");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: values.title.trim(),
|
||||||
|
status: values.status || "draft",
|
||||||
|
region_id: Number(values.regionId || 0),
|
||||||
|
start_ms: startMs,
|
||||||
|
end_ms: endMs,
|
||||||
|
gift_ids: giftIds,
|
||||||
|
top1_resource_group_id: resourceGroupIds[0],
|
||||||
|
top2_resource_group_id: resourceGroupIds[1],
|
||||||
|
top3_resource_group_id: resourceGroupIds[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(cycle) {
|
||||||
|
const nextStatus = cycle.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await setWeeklyStarCycleStatus(cycle.cycleId, nextStatus);
|
||||||
|
setToast(nextStatus === "active" ? "配置已启用" : "配置已停用");
|
||||||
|
reloadCycles();
|
||||||
|
} catch (err) {
|
||||||
|
setToast(err.message || "状态更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLeaderboard(cycle) {
|
||||||
|
setLeaderboard({ cycle, loading: true, items: [], error: "" });
|
||||||
|
try {
|
||||||
|
const result = await listWeeklyStarLeaderboard(cycle.cycleId);
|
||||||
|
setLeaderboard({ cycle, loading: false, items: result.items || [], error: "" });
|
||||||
|
} catch (err) {
|
||||||
|
setLeaderboard({ cycle, loading: false, items: [], error: err.message || "加载排行榜失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettlements(cycle) {
|
||||||
|
setSettlements({ cycle, loading: true, items: [], error: "" });
|
||||||
|
try {
|
||||||
|
const result = await listWeeklyStarSettlements(cycle.cycleId);
|
||||||
|
setSettlements({ cycle, loading: false, items: result || [], error: "" });
|
||||||
|
} catch (err) {
|
||||||
|
setSettlements({ cycle, loading: false, items: [], error: err.message || "加载结算结果失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={reloadCycles}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={openCreate}>
|
||||||
|
新增周期
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={error} loading={loading} onRetry={reloadCycles}>
|
||||||
|
<AdminListBody>
|
||||||
|
<TableContainer sx={{ minWidth: 1200 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>周期</TableCell>
|
||||||
|
<TableCell>区域</TableCell>
|
||||||
|
<TableCell>UTC 时间</TableCell>
|
||||||
|
<TableCell>礼物</TableCell>
|
||||||
|
<TableCell>奖励资源组</TableCell>
|
||||||
|
<TableCell>状态</TableCell>
|
||||||
|
<TableCell align="right">操作</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{cycles.map((cycle) => (
|
||||||
|
<TableRow hover key={cycle.cycleId}>
|
||||||
|
<TableCell>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography fontWeight={700}>{cycle.title || "-"}</Typography>
|
||||||
|
<Typography color="text.secondary" variant="caption">
|
||||||
|
{cycle.cycleId}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{cycle.regionId === 0 ? "默认区域 · 0" : cycle.regionId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<span>{formatUTC(cycle.startMs)}</span>
|
||||||
|
<Typography color="text.secondary" variant="caption">
|
||||||
|
至 {formatUTC(cycle.endMs)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||||
|
{cycle.gifts.map((gift) => (
|
||||||
|
<Chip
|
||||||
|
key={gift.giftId}
|
||||||
|
label={giftLabel(gift.giftId, giftOptions)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||||
|
{cycle.rewards.map((reward) => (
|
||||||
|
<Chip
|
||||||
|
key={reward.rankNo}
|
||||||
|
label={`Top${reward.rankNo}: ${groupLabel(
|
||||||
|
reward.resourceGroupId,
|
||||||
|
resourceGroupOptions,
|
||||||
|
)}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
color={statusColor(cycle.status)}
|
||||||
|
label={statusLabel(cycle.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<EditOutlined fontSize="small" />}
|
||||||
|
onClick={() => openEdit(cycle)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={cycle.status === "settled" || cycle.status === "settling"}
|
||||||
|
variant={cycle.status === "active" ? "danger" : "success"}
|
||||||
|
onClick={() => toggleStatus(cycle)}
|
||||||
|
>
|
||||||
|
{cycle.status === "active" ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<BarChartOutlined fontSize="small" />}
|
||||||
|
onClick={() => openLeaderboard(cycle)}
|
||||||
|
>
|
||||||
|
排行榜
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<WorkspacePremiumOutlined fontSize="small" />}
|
||||||
|
onClick={() => openSettlements(cycle)}
|
||||||
|
>
|
||||||
|
结算
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!cycles.length ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
<Box sx={{ py: 6, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
暂无周星周期配置
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<Drawer anchor="right" open={drawerOpen} PaperProps={{ sx: { width: 520 } }} onClose={closeDrawer}>
|
||||||
|
<Box component="form" sx={{ p: 3 }} onSubmit={submitForm}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">{form.cycleId ? "编辑周星周期" : "新增周星周期"}</Typography>
|
||||||
|
<Chip label="UTC epoch ms" size="small" />
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={2.25}>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
label="活动标题"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="区域 ID"
|
||||||
|
type="number"
|
||||||
|
value={form.regionId}
|
||||||
|
helperText="0 表示默认区域"
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, regionId: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>状态</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="状态"
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, status: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusOptions.map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
label="开始时间 UTC"
|
||||||
|
placeholder="2026-06-01T00:00"
|
||||||
|
value={form.startAt}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, startAt: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
label="结束时间 UTC"
|
||||||
|
placeholder="2026-06-08T00:00"
|
||||||
|
value={form.endAt}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, endAt: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
<Typography fontWeight={700}>活动礼物</Typography>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<OptionField
|
||||||
|
key={index}
|
||||||
|
label={`礼物 ${index + 1}`}
|
||||||
|
options={giftOptions}
|
||||||
|
value={form.giftIds[index]}
|
||||||
|
onChange={(value) => updateGift(index, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Divider />
|
||||||
|
<Typography fontWeight={700}>Top1 / Top2 / Top3 资源组</Typography>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<OptionField
|
||||||
|
key={index}
|
||||||
|
label={`Top${index + 1} 资源组 ID`}
|
||||||
|
options={resourceGroupOptions}
|
||||||
|
value={form.resourceGroupIds[index]}
|
||||||
|
onChange={(value) => updateReward(index, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={1.5} sx={{ pt: 1 }}>
|
||||||
|
<Button disabled={saving} onClick={closeDrawer}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
startIcon={<EventAvailableOutlined fontSize="small" />}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<ResultDialog
|
||||||
|
columns={["排名", "用户 ID", "积分", "首次计分 UTC", "最后计分 UTC"]}
|
||||||
|
emptyText="暂无排行榜数据"
|
||||||
|
error={leaderboard.error}
|
||||||
|
loading={leaderboard.loading}
|
||||||
|
open={Boolean(leaderboard.cycle)}
|
||||||
|
rows={leaderboard.items}
|
||||||
|
title={leaderboard.cycle ? `排行榜 · ${leaderboard.cycle.title}` : "排行榜"}
|
||||||
|
onClose={() => setLeaderboard({ cycle: null, loading: false, items: [], error: "" })}
|
||||||
|
renderRow={(item) => [
|
||||||
|
item.rankNo,
|
||||||
|
item.userId,
|
||||||
|
formatNumber(item.score),
|
||||||
|
formatUTC(item.firstScoredAtMs),
|
||||||
|
formatUTC(item.lastScoredAtMs),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ResultDialog
|
||||||
|
columns={["排名", "用户 ID", "积分", "资源组", "状态", "钱包命令"]}
|
||||||
|
emptyText="暂无结算结果"
|
||||||
|
error={settlements.error}
|
||||||
|
loading={settlements.loading}
|
||||||
|
open={Boolean(settlements.cycle)}
|
||||||
|
rows={settlements.items}
|
||||||
|
title={settlements.cycle ? `结算结果 · ${settlements.cycle.title}` : "结算结果"}
|
||||||
|
onClose={() => setSettlements({ cycle: null, loading: false, items: [], error: "" })}
|
||||||
|
renderRow={(item) => [
|
||||||
|
item.rankNo,
|
||||||
|
item.userId,
|
||||||
|
formatNumber(item.score),
|
||||||
|
groupLabel(item.resourceGroupId, resourceGroupOptions),
|
||||||
|
item.status || "-",
|
||||||
|
item.walletCommandId || "-",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Snackbar autoHideDuration={3200} open={Boolean(toast)} onClose={() => setToast("")}>
|
||||||
|
<Alert severity="info" variant="filled" onClose={() => setToast("")}>
|
||||||
|
{toast}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionField({ label, onChange, options, value }) {
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={options}
|
||||||
|
value={optionForValue(value, options)}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label || "")}
|
||||||
|
isOptionEqualToValue={(option, selected) => option.value === selected.value}
|
||||||
|
onChange={(_, next) => onChange(typeof next === "string" ? next : next?.value || "")}
|
||||||
|
onInputChange={(_, next, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => <TextField {...params} required label={label} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultDialog({ columns, emptyText, error, loading, onClose, open, renderRow, rows, title }) {
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} PaperProps={{ sx: { width: 720 } }} onClose={onClose}>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography sx={{ mb: 2 }} variant="h6">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{loading ? (
|
||||||
|
<Typography color="text.secondary">加载中...</Typography>
|
||||||
|
) : error ? (
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column}>{column}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<TableRow key={`${row.userId || row.settlementId || rowIndex}-${rowIndex}`}>
|
||||||
|
{renderRow(row).map((cell, cellIndex) => (
|
||||||
|
<TableCell key={cellIndex}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!rows.length ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length}>
|
||||||
|
<Box sx={{ py: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{emptyText}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionForValue(value, options) {
|
||||||
|
const normalized = String(value || "");
|
||||||
|
return options.find((option) => option.value === normalized) || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giftLabel(giftId, options) {
|
||||||
|
return options.find((option) => option.value === String(giftId))?.label || giftId || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabel(groupId, options) {
|
||||||
|
return options.find((option) => option.value === String(groupId))?.label || groupId || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status) {
|
||||||
|
if (status === "active") return "success";
|
||||||
|
if (status === "disabled") return "default";
|
||||||
|
if (status === "settled") return "primary";
|
||||||
|
if (status === "settling") return "warning";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUTCInput(value) {
|
||||||
|
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
|
||||||
|
if (!match) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUTCInput(value) {
|
||||||
|
const ms = Number(value || 0);
|
||||||
|
if (!ms) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const date = new Date(ms);
|
||||||
|
return (
|
||||||
|
[date.getUTCFullYear(), pad(date.getUTCMonth() + 1), pad(date.getUTCDate())].join("-") +
|
||||||
|
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUTC(value) {
|
||||||
|
const ms = Number(value || 0);
|
||||||
|
if (!ms) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||||
|
}
|
||||||
12
src/features/weekly-star/routes.js
Normal file
12
src/features/weekly-star/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const weeklyStarRoutes = [
|
||||||
|
{
|
||||||
|
label: "周星配置",
|
||||||
|
loader: () => import("./pages/WeeklyStarPage.jsx").then((module) => module.WeeklyStarPage),
|
||||||
|
menuCode: MENU_CODES.weeklyStar,
|
||||||
|
pageKey: "weekly-star",
|
||||||
|
path: "/activities/weekly-star",
|
||||||
|
permission: PERMISSIONS.weeklyStarView,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -54,6 +54,7 @@ export const API_OPERATIONS = {
|
|||||||
createTeamSalaryPolicy: "createTeamSalaryPolicy",
|
createTeamSalaryPolicy: "createTeamSalaryPolicy",
|
||||||
createTier: "createTier",
|
createTier: "createTier",
|
||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
|
createWeeklyStarCycle: "createWeeklyStarCycle",
|
||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
deleteAgency: "deleteAgency",
|
deleteAgency: "deleteAgency",
|
||||||
@ -88,6 +89,7 @@ export const API_OPERATIONS = {
|
|||||||
exportUsers: "exportUsers",
|
exportUsers: "exportUsers",
|
||||||
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||||
getCountry: "getCountry",
|
getCountry: "getCountry",
|
||||||
|
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||||
@ -100,8 +102,10 @@ export const API_OPERATIONS = {
|
|||||||
getRoleDataScopes: "getRoleDataScopes",
|
getRoleDataScopes: "getRoleDataScopes",
|
||||||
getRoomConfig: "getRoomConfig",
|
getRoomConfig: "getRoomConfig",
|
||||||
getRoomRocketConfig: "getRoomRocketConfig",
|
getRoomRocketConfig: "getRoomRocketConfig",
|
||||||
|
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
|
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||||
grantResource: "grantResource",
|
grantResource: "grantResource",
|
||||||
grantResourceGroup: "grantResourceGroup",
|
grantResourceGroup: "grantResourceGroup",
|
||||||
listAchievementDefinitions: "listAchievementDefinitions",
|
listAchievementDefinitions: "listAchievementDefinitions",
|
||||||
@ -117,6 +121,7 @@ export const API_OPERATIONS = {
|
|||||||
listCoinSellerLedger: "listCoinSellerLedger",
|
listCoinSellerLedger: "listCoinSellerLedger",
|
||||||
listCoinSellers: "listCoinSellers",
|
listCoinSellers: "listCoinSellers",
|
||||||
listCountries: "listCountries",
|
listCountries: "listCountries",
|
||||||
|
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
|
||||||
listEmojiPackCategories: "listEmojiPackCategories",
|
listEmojiPackCategories: "listEmojiPackCategories",
|
||||||
listEmojiPacks: "listEmojiPacks",
|
listEmojiPacks: "listEmojiPacks",
|
||||||
listExploreTabs: "listExploreTabs",
|
listExploreTabs: "listExploreTabs",
|
||||||
@ -148,12 +153,16 @@ export const API_OPERATIONS = {
|
|||||||
listRoles: "listRoles",
|
listRoles: "listRoles",
|
||||||
listRoomPins: "listRoomPins",
|
listRoomPins: "listRoomPins",
|
||||||
listRooms: "listRooms",
|
listRooms: "listRooms",
|
||||||
|
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
|
||||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||||
listSystemMenus: "listSystemMenus",
|
listSystemMenus: "listSystemMenus",
|
||||||
listTaskDefinitions: "listTaskDefinitions",
|
listTaskDefinitions: "listTaskDefinitions",
|
||||||
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||||
listUserLeaderboards: "listUserLeaderboards",
|
listUserLeaderboards: "listUserLeaderboards",
|
||||||
listUsers: "listUsers",
|
listUsers: "listUsers",
|
||||||
|
listWeeklyStarCycles: "listWeeklyStarCycles",
|
||||||
|
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
||||||
|
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
||||||
login: "login",
|
login: "login",
|
||||||
logout: "logout",
|
logout: "logout",
|
||||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||||
@ -169,6 +178,7 @@ export const API_OPERATIONS = {
|
|||||||
replaceRolePermissions: "replaceRolePermissions",
|
replaceRolePermissions: "replaceRolePermissions",
|
||||||
resetUserPassword: "resetUserPassword",
|
resetUserPassword: "resetUserPassword",
|
||||||
retryRedPacketRefund: "retryRedPacketRefund",
|
retryRedPacketRefund: "retryRedPacketRefund",
|
||||||
|
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
||||||
search: "search",
|
search: "search",
|
||||||
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
||||||
setBDLeaderStatus: "setBDLeaderStatus",
|
setBDLeaderStatus: "setBDLeaderStatus",
|
||||||
@ -176,6 +186,7 @@ export const API_OPERATIONS = {
|
|||||||
setCoinSellerStatus: "setCoinSellerStatus",
|
setCoinSellerStatus: "setCoinSellerStatus",
|
||||||
setGameStatus: "setGameStatus",
|
setGameStatus: "setGameStatus",
|
||||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||||
|
setWeeklyStarCycleStatus: "setWeeklyStarCycleStatus",
|
||||||
sortMenus: "sortMenus",
|
sortMenus: "sortMenus",
|
||||||
syncPermissions: "syncPermissions",
|
syncPermissions: "syncPermissions",
|
||||||
updateAchievementDefinition: "updateAchievementDefinition",
|
updateAchievementDefinition: "updateAchievementDefinition",
|
||||||
@ -183,6 +194,7 @@ export const API_OPERATIONS = {
|
|||||||
updateBanner: "updateBanner",
|
updateBanner: "updateBanner",
|
||||||
updateCatalog: "updateCatalog",
|
updateCatalog: "updateCatalog",
|
||||||
updateCountry: "updateCountry",
|
updateCountry: "updateCountry",
|
||||||
|
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||||
updateExploreTab: "updateExploreTab",
|
updateExploreTab: "updateExploreTab",
|
||||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||||
updateGift: "updateGift",
|
updateGift: "updateGift",
|
||||||
@ -206,6 +218,7 @@ export const API_OPERATIONS = {
|
|||||||
updateRoom: "updateRoom",
|
updateRoom: "updateRoom",
|
||||||
updateRoomConfig: "updateRoomConfig",
|
updateRoomConfig: "updateRoomConfig",
|
||||||
updateRoomRocketConfig: "updateRoomRocketConfig",
|
updateRoomRocketConfig: "updateRoomRocketConfig",
|
||||||
|
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
|
||||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||||
updateTaskDefinition: "updateTaskDefinition",
|
updateTaskDefinition: "updateTaskDefinition",
|
||||||
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
||||||
@ -213,6 +226,7 @@ export const API_OPERATIONS = {
|
|||||||
updateTrack: "updateTrack",
|
updateTrack: "updateTrack",
|
||||||
updateUser: "updateUser",
|
updateUser: "updateUser",
|
||||||
updateUserStatus: "updateUserStatus",
|
updateUserStatus: "updateUserStatus",
|
||||||
|
updateWeeklyStarCycle: "updateWeeklyStarCycle",
|
||||||
uploadFile: "uploadFile",
|
uploadFile: "uploadFile",
|
||||||
uploadImage: "uploadImage",
|
uploadImage: "uploadImage",
|
||||||
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
||||||
@ -508,6 +522,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:create",
|
permission: "user:create",
|
||||||
permissions: ["user:create"]
|
permissions: ["user:create"]
|
||||||
},
|
},
|
||||||
|
createWeeklyStarCycle: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.createWeeklyStarCycle,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles",
|
||||||
|
permission: "weekly-star:create",
|
||||||
|
permissions: ["weekly-star:create"]
|
||||||
|
},
|
||||||
creditCoinSellerStock: {
|
creditCoinSellerStock: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.creditCoinSellerStock,
|
operationId: API_OPERATIONS.creditCoinSellerStock,
|
||||||
@ -746,6 +767,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:view",
|
permission: "country:view",
|
||||||
permissions: ["country:view"]
|
permissions: ["country:view"]
|
||||||
},
|
},
|
||||||
|
getCumulativeRechargeRewardConfig: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getCumulativeRechargeRewardConfig,
|
||||||
|
path: "/v1/admin/activity/cumulative-recharge-reward/config",
|
||||||
|
permission: "cumulative-recharge-reward:view",
|
||||||
|
permissions: ["cumulative-recharge-reward:view"]
|
||||||
|
},
|
||||||
getFirstRechargeRewardConfig: {
|
getFirstRechargeRewardConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getFirstRechargeRewardConfig,
|
operationId: API_OPERATIONS.getFirstRechargeRewardConfig,
|
||||||
@ -830,6 +858,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-rocket:view",
|
permission: "room-rocket:view",
|
||||||
permissions: ["room-rocket:view"]
|
permissions: ["room-rocket:view"]
|
||||||
},
|
},
|
||||||
|
getRoomTurnoverRewardConfig: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getRoomTurnoverRewardConfig,
|
||||||
|
path: "/v1/admin/activity/room-turnover-reward/config",
|
||||||
|
permission: "room-turnover-reward:view",
|
||||||
|
permissions: ["room-turnover-reward:view"]
|
||||||
|
},
|
||||||
getSevenDayCheckInConfig: {
|
getSevenDayCheckInConfig: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
||||||
@ -844,6 +879,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:view",
|
permission: "user:view",
|
||||||
permissions: ["user:view"]
|
permissions: ["user:view"]
|
||||||
},
|
},
|
||||||
|
getWeeklyStarCycle: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getWeeklyStarCycle,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles/{cycle_id}",
|
||||||
|
permission: "weekly-star:view",
|
||||||
|
permissions: ["weekly-star:view"]
|
||||||
|
},
|
||||||
grantResource: {
|
grantResource: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.grantResource,
|
operationId: API_OPERATIONS.grantResource,
|
||||||
@ -947,6 +989,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:view",
|
permission: "country:view",
|
||||||
permissions: ["country:view"]
|
permissions: ["country:view"]
|
||||||
},
|
},
|
||||||
|
listCumulativeRechargeRewardGrants: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listCumulativeRechargeRewardGrants,
|
||||||
|
path: "/v1/admin/activity/cumulative-recharge-reward/grants",
|
||||||
|
permission: "cumulative-recharge-reward:view",
|
||||||
|
permissions: ["cumulative-recharge-reward:view"]
|
||||||
|
},
|
||||||
listEmojiPackCategories: {
|
listEmojiPackCategories: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listEmojiPackCategories,
|
operationId: API_OPERATIONS.listEmojiPackCategories,
|
||||||
@ -1164,6 +1213,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room:view",
|
permission: "room:view",
|
||||||
permissions: ["room:view"]
|
permissions: ["room:view"]
|
||||||
},
|
},
|
||||||
|
listRoomTurnoverRewardSettlements: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listRoomTurnoverRewardSettlements,
|
||||||
|
path: "/v1/admin/activity/room-turnover-reward/settlements",
|
||||||
|
permission: "room-turnover-reward:view",
|
||||||
|
permissions: ["room-turnover-reward:view"]
|
||||||
|
},
|
||||||
listSevenDayCheckInClaims: {
|
listSevenDayCheckInClaims: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
|
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
|
||||||
@ -1206,6 +1262,27 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:view",
|
permission: "user:view",
|
||||||
permissions: ["user:view"]
|
permissions: ["user:view"]
|
||||||
},
|
},
|
||||||
|
listWeeklyStarCycles: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listWeeklyStarCycles,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles",
|
||||||
|
permission: "weekly-star:view",
|
||||||
|
permissions: ["weekly-star:view"]
|
||||||
|
},
|
||||||
|
listWeeklyStarLeaderboard: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listWeeklyStarLeaderboard,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles/{cycle_id}/leaderboard",
|
||||||
|
permission: "weekly-star:view",
|
||||||
|
permissions: ["weekly-star:view"]
|
||||||
|
},
|
||||||
|
listWeeklyStarSettlements: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listWeeklyStarSettlements,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles/{cycle_id}/settlements",
|
||||||
|
permission: "weekly-star:settle",
|
||||||
|
permissions: ["weekly-star:settle"]
|
||||||
|
},
|
||||||
login: {
|
login: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.login,
|
operationId: API_OPERATIONS.login,
|
||||||
@ -1301,6 +1378,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "red-packet:update",
|
permission: "red-packet:update",
|
||||||
permissions: ["red-packet:update"]
|
permissions: ["red-packet:update"]
|
||||||
},
|
},
|
||||||
|
retryRoomTurnoverRewardSettlement: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.retryRoomTurnoverRewardSettlement,
|
||||||
|
path: "/v1/admin/activity/room-turnover-reward/settlements/{settlement_id}/retry",
|
||||||
|
permission: "room-turnover-reward:retry",
|
||||||
|
permissions: ["room-turnover-reward:retry"]
|
||||||
|
},
|
||||||
search: {
|
search: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.search,
|
operationId: API_OPERATIONS.search,
|
||||||
@ -1348,6 +1432,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "daily-task:status",
|
permission: "daily-task:status",
|
||||||
permissions: ["daily-task:status"]
|
permissions: ["daily-task:status"]
|
||||||
},
|
},
|
||||||
|
setWeeklyStarCycleStatus: {
|
||||||
|
method: "PATCH",
|
||||||
|
operationId: API_OPERATIONS.setWeeklyStarCycleStatus,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles/{cycle_id}/status",
|
||||||
|
permission: "weekly-star:update",
|
||||||
|
permissions: ["weekly-star:update"]
|
||||||
|
},
|
||||||
sortMenus: {
|
sortMenus: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.sortMenus,
|
operationId: API_OPERATIONS.sortMenus,
|
||||||
@ -1397,6 +1488,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "country:update",
|
permission: "country:update",
|
||||||
permissions: ["country:update"]
|
permissions: ["country:update"]
|
||||||
},
|
},
|
||||||
|
updateCumulativeRechargeRewardConfig: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.updateCumulativeRechargeRewardConfig,
|
||||||
|
path: "/v1/admin/activity/cumulative-recharge-reward/config",
|
||||||
|
permission: "cumulative-recharge-reward:update",
|
||||||
|
permissions: ["cumulative-recharge-reward:update"]
|
||||||
|
},
|
||||||
updateExploreTab: {
|
updateExploreTab: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateExploreTab,
|
operationId: API_OPERATIONS.updateExploreTab,
|
||||||
@ -1558,6 +1656,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "room-rocket:update",
|
permission: "room-rocket:update",
|
||||||
permissions: ["room-rocket:update"]
|
permissions: ["room-rocket:update"]
|
||||||
},
|
},
|
||||||
|
updateRoomTurnoverRewardConfig: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.updateRoomTurnoverRewardConfig,
|
||||||
|
path: "/v1/admin/activity/room-turnover-reward/config",
|
||||||
|
permission: "room-turnover-reward:update",
|
||||||
|
permissions: ["room-turnover-reward:update"]
|
||||||
|
},
|
||||||
updateSevenDayCheckInConfig: {
|
updateSevenDayCheckInConfig: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
||||||
@ -1607,6 +1712,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "user:status",
|
permission: "user:status",
|
||||||
permissions: ["user:status"]
|
permissions: ["user:status"]
|
||||||
},
|
},
|
||||||
|
updateWeeklyStarCycle: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.updateWeeklyStarCycle,
|
||||||
|
path: "/v1/admin/activity/weekly-star/cycles/{cycle_id}",
|
||||||
|
permission: "weekly-star:update",
|
||||||
|
permissions: ["weekly-star:update"]
|
||||||
|
},
|
||||||
uploadFile: {
|
uploadFile: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.uploadFile,
|
operationId: API_OPERATIONS.uploadFile,
|
||||||
|
|||||||
337
src/shared/api/generated/schema.d.ts
vendored
337
src/shared/api/generated/schema.d.ts
vendored
@ -244,6 +244,134 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/cumulative-recharge-reward/grants": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listCumulativeRechargeRewardGrants"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/room-turnover-reward/settlements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listRoomTurnoverRewardSettlements"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/room-turnover-reward/settlements/{settlement_id}/retry": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["retryRoomTurnoverRewardSettlement"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/weekly-star/cycles": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listWeeklyStarCycles"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createWeeklyStarCycle"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getWeeklyStarCycle"];
|
||||||
|
put: operations["updateWeeklyStarCycle"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/status": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["setWeeklyStarCycleStatus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/leaderboard": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listWeeklyStarLeaderboard"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/weekly-star/cycles/{cycle_id}/settlements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listWeeklyStarSettlements"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/registration-reward/claims": {
|
"/admin/activity/registration-reward/claims": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -276,6 +404,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/cumulative-recharge-reward/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getCumulativeRechargeRewardConfig"];
|
||||||
|
put: operations["updateCumulativeRechargeRewardConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/room-turnover-reward/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getRoomTurnoverRewardConfig"];
|
||||||
|
put: operations["updateRoomTurnoverRewardConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/registration-reward/config": {
|
"/admin/activity/registration-reward/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2994,7 +3154,7 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
listRegistrationRewardClaims: {
|
listCumulativeRechargeRewardGrants: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@ -3006,6 +3166,133 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listRoomTurnoverRewardSettlements: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
retryRoomTurnoverRewardSettlement: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listWeeklyStarCycles: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createWeeklyStarCycle: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getWeeklyStarCycle: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateWeeklyStarCycle: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
setWeeklyStarCycleStatus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listWeeklyStarLeaderboard: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listWeeklyStarSettlements: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listRegistrationRewardClaims: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
claimed_start_ms?: number;
|
||||||
|
claimed_end_ms?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getFirstRechargeRewardConfig: {
|
getFirstRechargeRewardConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3030,6 +3317,54 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getCumulativeRechargeRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateCumulativeRechargeRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getRoomTurnoverRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateRoomTurnoverRewardConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getRegistrationRewardConfig: {
|
getRegistrationRewardConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export interface PageQuery {
|
|||||||
[key: string]: boolean | number | string | null | undefined;
|
[key: string]: boolean | number | string | null | undefined;
|
||||||
agency_id?: EntityId;
|
agency_id?: EntityId;
|
||||||
agencyId?: EntityId;
|
agencyId?: EntityId;
|
||||||
|
claimed_end_ms?: number | string;
|
||||||
|
claimed_start_ms?: number | string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
displayScope?: string;
|
displayScope?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
@ -222,6 +224,8 @@ export interface CoinSellerLedgerDto {
|
|||||||
entryId: number;
|
entryId: number;
|
||||||
ledgerType?: string;
|
ledgerType?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
operator?: CoinAdjustmentOperatorDto;
|
||||||
|
operatorUserId?: string;
|
||||||
receiver: CoinLedgerUserDto;
|
receiver: CoinLedgerUserDto;
|
||||||
seller: CoinLedgerUserDto;
|
seller: CoinLedgerUserDto;
|
||||||
sellerBalanceAfter: number;
|
sellerBalanceAfter: number;
|
||||||
@ -468,6 +472,7 @@ export interface CoinSellerDto {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
totalRechargeUsdtMicro?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|||||||
@ -72,5 +72,34 @@ function itemKey(item, index) {
|
|||||||
if (!item || typeof item !== "object") {
|
if (!item || typeof item !== "object") {
|
||||||
return `value:${String(item)}:${index}`;
|
return `value:${String(item)}:${index}`;
|
||||||
}
|
}
|
||||||
return item.id ?? item.userId ?? item.roleId ?? item.permissionId ?? item.groupId ?? item.resourceId ?? item.claimId ?? item.key ?? `index:${index}`;
|
for (const key of paginatedIdentityKeys) {
|
||||||
|
const value = item[key];
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
return `${key}:${String(value)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `index:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paginatedIdentityKeys = [
|
||||||
|
"id",
|
||||||
|
"entryId",
|
||||||
|
"transactionId",
|
||||||
|
"billId",
|
||||||
|
"orderId",
|
||||||
|
"recordId",
|
||||||
|
"logId",
|
||||||
|
"settlementId",
|
||||||
|
"claimId",
|
||||||
|
"grantId",
|
||||||
|
"reportId",
|
||||||
|
"roleId",
|
||||||
|
"permissionId",
|
||||||
|
"groupId",
|
||||||
|
"resourceId",
|
||||||
|
"roomId",
|
||||||
|
"giftId",
|
||||||
|
"taskId",
|
||||||
|
"userId",
|
||||||
|
"key"
|
||||||
|
];
|
||||||
|
|||||||
39
src/shared/hooks/usePaginatedQuery.test.js
Normal file
39
src/shared/hooks/usePaginatedQuery.test.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { mergePaginatedItems } from "./usePaginatedQuery.js";
|
||||||
|
|
||||||
|
test("mergePaginatedItems keeps ledger rows with the same user but different entry ids", () => {
|
||||||
|
const merged = mergePaginatedItems(
|
||||||
|
[
|
||||||
|
{ entryId: 1, userId: "1001", amount: 100 },
|
||||||
|
{ entryId: 2, userId: "1001", amount: 50 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ entryId: 3, userId: "1001", amount: 20 },
|
||||||
|
{ entryId: 4, userId: "1002", amount: 10 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.map((item) => item.entryId)).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePaginatedItems still removes duplicated rows by record id", () => {
|
||||||
|
const merged = mergePaginatedItems(
|
||||||
|
[{ entryId: 1, userId: "1001", amount: 100 }],
|
||||||
|
[{ entryId: 1, userId: "1001", amount: 100 }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].entryId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePaginatedItems can still merge entity lists by user id", () => {
|
||||||
|
const merged = mergePaginatedItems(
|
||||||
|
[{ userId: "1001", username: "A" }],
|
||||||
|
[
|
||||||
|
{ userId: "1001", username: "A" },
|
||||||
|
{ userId: "1002", username: "B" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.map((item) => item.userId)).toEqual(["1001", "1002"]);
|
||||||
|
});
|
||||||
108
src/shared/ui/InlineEditableCell.jsx
Normal file
108
src/shared/ui/InlineEditableCell.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function InlineEditableCell({
|
||||||
|
buttonTitle,
|
||||||
|
className = "",
|
||||||
|
disabledTitle,
|
||||||
|
displayValue,
|
||||||
|
editable = true,
|
||||||
|
emptyLabel = "-",
|
||||||
|
inputProps,
|
||||||
|
maxWidth = "100%",
|
||||||
|
normalizeValue = (value) => String(value ?? "").trim(),
|
||||||
|
onSave,
|
||||||
|
saving = false,
|
||||||
|
type = "text",
|
||||||
|
value,
|
||||||
|
variant = "default",
|
||||||
|
width = "100%",
|
||||||
|
}) {
|
||||||
|
const originalValue = String(value ?? "");
|
||||||
|
const shownValue = displayValue ?? originalValue;
|
||||||
|
const canEdit = Boolean(editable && !saving);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(originalValue);
|
||||||
|
const skipBlurRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) {
|
||||||
|
setDraft(originalValue);
|
||||||
|
}
|
||||||
|
}, [editing, originalValue]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (skipBlurRef.current) {
|
||||||
|
skipBlurRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = normalizeValue(draft);
|
||||||
|
setEditing(false);
|
||||||
|
if (nextValue !== originalValue) {
|
||||||
|
void onSave?.(nextValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
skipBlurRef.current = true;
|
||||||
|
setDraft(originalValue);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
"--inline-edit-cell-max-width": maxWidth,
|
||||||
|
"--inline-edit-cell-width": width,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<span className={["inline-edit-cell", className].filter(Boolean).join(" ")} style={style}>
|
||||||
|
<button
|
||||||
|
className={[
|
||||||
|
"inline-edit-cell__button",
|
||||||
|
variant === "link" ? "inline-edit-cell__button--link" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={canEdit ? buttonTitle : disabledTitle}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (canEdit) {
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shownValue || emptyLabel}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={["inline-edit-cell", className].filter(Boolean).join(" ")} style={style}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
className="inline-edit-cell__input"
|
||||||
|
disabled={saving}
|
||||||
|
inputProps={inputProps}
|
||||||
|
size="small"
|
||||||
|
type={type}
|
||||||
|
value={draft}
|
||||||
|
onBlur={save}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -470,7 +470,7 @@
|
|||||||
.nav-list {
|
.nav-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -520,6 +520,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item--parent {
|
.nav-item--parent {
|
||||||
|
height: 36px;
|
||||||
padding-right: var(--space-3);
|
padding-right: var(--space-3);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
.kpi-grid,
|
.kpi-grid,
|
||||||
.panel-grid,
|
.panel-grid,
|
||||||
.overview-charts,
|
.overview-charts,
|
||||||
|
.frequent-menu-grid,
|
||||||
.user-kpi-row,
|
.user-kpi-row,
|
||||||
.page-skeleton__kpis,
|
.page-skeleton__kpis,
|
||||||
.page-skeleton__charts {
|
.page-skeleton__charts {
|
||||||
@ -101,6 +102,7 @@
|
|||||||
.kpi-grid,
|
.kpi-grid,
|
||||||
.panel-grid,
|
.panel-grid,
|
||||||
.overview-charts,
|
.overview-charts,
|
||||||
|
.frequent-menu-grid,
|
||||||
.user-kpi-row,
|
.user-kpi-row,
|
||||||
.alert-strip,
|
.alert-strip,
|
||||||
.page-skeleton__kpis,
|
.page-skeleton__kpis,
|
||||||
|
|||||||
@ -805,6 +805,32 @@
|
|||||||
width: min(560px, 100vw);
|
width: min(560px, 100vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(1040px, calc(100vw - 48px));
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .form-drawer__grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-scope {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .form-drawer__actions {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
.form-drawer h2 {
|
.form-drawer h2 {
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -917,6 +943,18 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-permission-menu-list {
|
||||||
|
display: block;
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-menu-list > .permission-menu-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-menu-section {
|
.permission-menu-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@ -995,6 +1033,40 @@
|
|||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .permission-menu-section {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .permission-menu-header {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .permission-check {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .permission-check span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.role-permission-drawer {
|
||||||
|
width: 100vw;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-drawer .form-drawer__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-permission-menu-list {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.batch-actions {
|
.batch-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@ -1272,6 +1344,85 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button {
|
||||||
|
display: inline-flex;
|
||||||
|
width: var(--inline-edit-cell-width, 100%);
|
||||||
|
max-width: min(var(--inline-edit-cell-max-width, 100%), 100%);
|
||||||
|
min-height: 30px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button:hover {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button:disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button--link {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
border: 0;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button--link:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button--link:disabled {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__button--link:disabled:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__input {
|
||||||
|
width: var(--inline-edit-cell-width, 100%);
|
||||||
|
max-width: min(var(--inline-edit-cell-max-width, 100%), 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-cell__input :is(.MuiInputBase-input) {
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table-filter-reset-wrap {
|
.admin-table-filter-reset-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user