活动相关配置
This commit is contained in:
parent
945e2e7047
commit
fd80e18aff
@ -270,6 +270,122 @@
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "listRegistrationRewardClaims",
|
||||
@ -278,6 +394,52 @@
|
||||
"$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-permissions": ["registration-reward:view"]
|
||||
}
|
||||
@ -304,6 +466,50 @@
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getRegistrationRewardConfig",
|
||||
|
||||
@ -8,7 +8,7 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
||||
import { MetricCard } from "./components/MetricCard.jsx";
|
||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||
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 = {
|
||||
appOptions: [],
|
||||
@ -17,7 +17,9 @@ const initialFilterOptions = {
|
||||
};
|
||||
|
||||
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 [countryId, setCountryId] = useState(0);
|
||||
const [regionId, setRegionId] = useState("all");
|
||||
@ -29,6 +31,7 @@ export function DatabiApp() {
|
||||
const [error, setError] = useState("");
|
||||
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
||||
const overviewRequestIdRef = useRef(0);
|
||||
const overviewRef = useRef(null);
|
||||
const scopedCountries = useMemo(() => {
|
||||
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
||||
}, [filterOptions.countryOptions, regionId]);
|
||||
@ -42,6 +45,15 @@ export function DatabiApp() {
|
||||
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 () => {
|
||||
setFiltersLoading(true);
|
||||
try {
|
||||
@ -60,15 +72,22 @@ export function DatabiApp() {
|
||||
}
|
||||
}, [appCode]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
overviewRef.current = overview;
|
||||
}, [overview]);
|
||||
|
||||
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
||||
const requestId = overviewRequestIdRef.current + 1;
|
||||
overviewRequestIdRef.current = requestId;
|
||||
const keepCurrentOverview = preserveData && overviewRef.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setOverview(null);
|
||||
if (!keepCurrentOverview) {
|
||||
setOverview(null);
|
||||
}
|
||||
try {
|
||||
const startMs = rangeStartMs(range);
|
||||
const endMs = rangeEndMs(range);
|
||||
const startMs = rangeStartMs(range, timeZone);
|
||||
const endMs = rangeEndMs(range, timeZone);
|
||||
const data = await fetchStatisticsOverview({
|
||||
appCode,
|
||||
countryId,
|
||||
@ -94,7 +113,9 @@ export function DatabiApp() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === overviewRequestIdRef.current) {
|
||||
setOverview(null);
|
||||
if (!keepCurrentOverview) {
|
||||
setOverview(null);
|
||||
}
|
||||
setError(err.message || "请求失败");
|
||||
}
|
||||
} finally {
|
||||
@ -102,7 +123,7 @@ export function DatabiApp() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appCode, countryId, range, regionId, scopedCountries]);
|
||||
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFilterOptions();
|
||||
@ -110,11 +131,15 @@ export function DatabiApp() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
const timer = window.setInterval(() => void loadOverview(), 60_000);
|
||||
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [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 openLuckyGiftPools = useCallback(() => {
|
||||
setPoolModalOpen(true);
|
||||
@ -133,12 +158,15 @@ export function DatabiApp() {
|
||||
loading={loading}
|
||||
onAppChange={handleAppChange}
|
||||
onCountryChange={setCountryId}
|
||||
onRefresh={loadOverview}
|
||||
onRefresh={handleRefresh}
|
||||
onRangeChange={setRange}
|
||||
onRegionChange={handleRegionChange}
|
||||
onTimeZoneChange={handleTimeZoneChange}
|
||||
range={range}
|
||||
regionId={regionId}
|
||||
regionOptions={filterOptions.regionOptions}
|
||||
timeZone={timeZone}
|
||||
timeZoneOptions={TIME_ZONE_OPTIONS}
|
||||
updatedAt={model.updatedAt}
|
||||
/>
|
||||
|
||||
@ -184,6 +212,34 @@ function hasCountryBreakdown(data) {
|
||||
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) {
|
||||
if (!regionId || regionId === "all") {
|
||||
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 KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
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 defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
|
||||
const rangeOptions = [
|
||||
{ label: "今日", value: "today", getRange: () => todayRange() },
|
||||
{ label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
|
||||
{ label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
|
||||
{ label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
|
||||
{ label: "本月", value: "month", getRange: thisMonthRange }
|
||||
{ label: "今日", value: "today", getRange: (timeZone) => todayRange(timeZone) },
|
||||
{ label: "近 7 日", value: "7d", getRange: (timeZone) => lastDaysRange(7, timeZone) },
|
||||
{ label: "近 14 日", value: "14d", getRange: (timeZone) => lastDaysRange(14, timeZone) },
|
||||
{ label: "近 30 日", value: "30d", getRange: (timeZone) => lastDaysRange(30, timeZone) },
|
||||
{ label: "本月", value: "month", getRange: (timeZone) => thisMonthRange(timeZone) }
|
||||
];
|
||||
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
|
||||
@ -28,14 +28,18 @@ export function DatabiHeader({
|
||||
onCountryChange,
|
||||
onRangeChange,
|
||||
onRegionChange,
|
||||
onTimeZoneChange,
|
||||
range,
|
||||
regionId,
|
||||
regionOptions = defaultRegionOptions
|
||||
regionOptions = defaultRegionOptions,
|
||||
timeZone,
|
||||
timeZoneOptions = TIME_ZONE_OPTIONS
|
||||
}) {
|
||||
const [openControl, setOpenControl] = useState("");
|
||||
const headerRef = useRef(null);
|
||||
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 timeZoneLabel = getTimeZoneLabel(timeZone);
|
||||
const filteredCountries = useMemo(() => {
|
||||
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
|
||||
return countryOptions;
|
||||
@ -44,7 +48,7 @@ export function DatabiHeader({
|
||||
}, [countryOptions, regionId, regionOptions]);
|
||||
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
|
||||
const activeRange = rangeOptions.find((item) => {
|
||||
const next = item.getRange();
|
||||
const next = item.getRange(timeZone);
|
||||
return sameRange(next, range);
|
||||
});
|
||||
|
||||
@ -77,6 +81,18 @@ export function DatabiHeader({
|
||||
</div>
|
||||
<div className="header-controls">
|
||||
<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>}
|
||||
isOpen={openControl === "app"}
|
||||
label="App"
|
||||
@ -93,6 +109,7 @@ export function DatabiHeader({
|
||||
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
|
||||
onToggle={() => toggleControl("range")}
|
||||
range={range}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<FilterMenu
|
||||
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 (
|
||||
<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">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span className="filter-copy">
|
||||
@ -200,8 +217,8 @@ function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect,
|
||||
);
|
||||
}
|
||||
|
||||
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range }) {
|
||||
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange() }));
|
||||
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone }) {
|
||||
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange(timeZone) }));
|
||||
const activeOption = options.find((item) => item.value === activeValue);
|
||||
const [draft, setDraft] = useState(() => normalizeRange(range));
|
||||
const [activeField, setActiveField] = useState("start");
|
||||
@ -254,7 +271,7 @@ function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range }) {
|
||||
|
||||
const applyDraft = () => {
|
||||
const normalized = normalizeRange(draft);
|
||||
if (rangeToMs(normalized, "start") > rangeToMs(normalized, "end")) {
|
||||
if (rangeStartMs(normalized, timeZone) > rangeEndMs(normalized, timeZone)) {
|
||||
setError("结束时间必须晚于开始时间");
|
||||
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) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
@ -374,10 +385,6 @@ function formatDateTimePart(date, 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) {
|
||||
return {
|
||||
end: range?.end || dateValue(new Date()),
|
||||
@ -423,10 +430,6 @@ function normalizeTime(value) {
|
||||
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) {
|
||||
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 { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.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 comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日";
|
||||
const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日";
|
||||
const recharge = effectiveRechargeUSDMinor(source);
|
||||
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
|
||||
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) }
|
||||
],
|
||||
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}`;
|
||||
}
|
||||
|
||||
function isTodayRange(range) {
|
||||
const today = dateValue(new Date());
|
||||
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 isTodayRange(range, timeZone) {
|
||||
return sameRange(range, todayRange(timeZone));
|
||||
}
|
||||
|
||||
function normalizeRevenueSeries(source) {
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
@ -94,7 +94,7 @@
|
||||
|
||||
.filter-trigger {
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
min-width: 138px;
|
||||
height: 44px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@ -109,6 +109,20 @@
|
||||
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[aria-expanded="true"] {
|
||||
border-color: rgba(39, 228, 245, 0.78);
|
||||
@ -164,7 +178,7 @@
|
||||
|
||||
.date-range-trigger {
|
||||
display: grid;
|
||||
width: 430px;
|
||||
width: 336px;
|
||||
height: 44px;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
@ -226,7 +240,7 @@
|
||||
.date-range-values b {
|
||||
overflow: hidden;
|
||||
color: #eaf6ff;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@ -1,13 +1,54 @@
|
||||
export function todayRange() {
|
||||
const today = new Date();
|
||||
return { end: dateInputValue(today), endTime: "23:59:59", start: dateInputValue(today), startTime: "00:00:00" };
|
||||
export const DEFAULT_TIME_ZONE = "Asia/Shanghai";
|
||||
export const TIME_ZONE_STORAGE_KEY = "hyapp-admin.display-time-zone";
|
||||
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) {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - days + 1);
|
||||
return { end: dateInputValue(end), endTime: "23:59:59", start: dateInputValue(start), startTime: "00:00:00" };
|
||||
export function lastDaysRange(days, timeZone = DEFAULT_TIME_ZONE) {
|
||||
const end = dateInputValueInTimeZone(new Date(), timeZone);
|
||||
const start = shiftDateValue(end, 1 - Number(days || 1));
|
||||
return { end, endTime: "23:59:59", 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) {
|
||||
@ -18,21 +59,35 @@ export function endOfDay(value) {
|
||||
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
||||
}
|
||||
|
||||
export function rangeEndMs(range) {
|
||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59");
|
||||
export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
|
||||
}
|
||||
|
||||
export function rangeStartMs(range) {
|
||||
return dateTimeMs(range?.start, range?.startTime || "00:00:00");
|
||||
export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||
return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone);
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
const date = new Date(Number(value) || Date.now());
|
||||
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
export function formatDateTime(value, timeZone = DEFAULT_TIME_ZONE) {
|
||||
const millis = Number(value) || Date.now();
|
||||
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) {
|
||||
return dateValue ? new Date(`${dateValue}T${normalizeTime(timeValue)}`).getTime() : "";
|
||||
export function sameRange(left, right) {
|
||||
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) {
|
||||
@ -40,8 +95,53 @@ function normalizeTime(value) {
|
||||
return `${pad2(parts[0] || 0)}:${pad2(parts[1] || 0)}:${pad2(parts[2] || 0)}`;
|
||||
}
|
||||
|
||||
function dateInputValue(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
function dateInputValueInTimeZone(date, timeZone) {
|
||||
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) {
|
||||
|
||||
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 { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { getMenus } from "@/features/menus/api";
|
||||
@ -11,6 +11,7 @@ import {
|
||||
mapBackendMenus,
|
||||
mergeNavigationItems
|
||||
} from "@/app/navigation/menu.js";
|
||||
import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js";
|
||||
import { Header } from "./Header.jsx";
|
||||
import { Sidebar } from "./Sidebar.jsx";
|
||||
|
||||
@ -18,7 +19,7 @@ export function AdminLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { can } = useAuth();
|
||||
const { can, user } = useAuth();
|
||||
const { refresh } = useRefreshSignal();
|
||||
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
|
||||
errorMessage: "加载菜单失败",
|
||||
@ -35,6 +36,10 @@ export function AdminLayout() {
|
||||
}, [backendMenus, can]);
|
||||
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
|
||||
|
||||
useEffect(() => {
|
||||
recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user });
|
||||
}, [location.pathname, menus, user]);
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
|
||||
<Header
|
||||
@ -50,7 +55,7 @@ export function AdminLayout() {
|
||||
|
||||
<main className="workspace">
|
||||
<div className="page-transition" key={location.pathname}>
|
||||
<Outlet />
|
||||
<Outlet context={{ menus }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -20,12 +20,14 @@ import MapOutlined from "@mui/icons-material/MapOutlined";
|
||||
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
|
||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||
import StarBorderOutlined from "@mui/icons-material/StarBorderOutlined";
|
||||
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
|
||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||
@ -58,6 +60,7 @@ const iconMap = {
|
||||
map: MapOutlined,
|
||||
menu: MenuOpenOutlined,
|
||||
operations: ReceiptLongOutlined,
|
||||
paid: PaidOutlined,
|
||||
public: PublicOutlined,
|
||||
push_pin: PushPinOutlined,
|
||||
receipt: ReceiptLongOutlined,
|
||||
@ -67,6 +70,7 @@ const iconMap = {
|
||||
settings: SettingsApplicationsOutlined,
|
||||
shield: ShieldOutlined,
|
||||
sports_esports: SportsEsportsOutlined,
|
||||
star: StarBorderOutlined,
|
||||
storefront: StorefrontOutlined,
|
||||
task: TaskAltOutlined,
|
||||
users: ManageAccountsOutlined,
|
||||
@ -74,21 +78,25 @@ const iconMap = {
|
||||
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 = [
|
||||
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",
|
||||
icon: ManageAccountsOutlined,
|
||||
@ -101,6 +109,22 @@ export const fallbackNavigation = [
|
||||
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",
|
||||
icon: BedroomParentOutlined,
|
||||
@ -153,6 +177,13 @@ export const fallbackNavigation = [
|
||||
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "payment",
|
||||
icon: WalletOutlined,
|
||||
id: "payment",
|
||||
label: "支付管理",
|
||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||
},
|
||||
{
|
||||
code: "activities",
|
||||
icon: CampaignOutlined,
|
||||
@ -162,20 +193,16 @@ export const fallbackNavigation = [
|
||||
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
|
||||
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
|
||||
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
|
||||
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
|
||||
routeNavItem("room-rocket", { icon: RedeemOutlined }),
|
||||
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
|
||||
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
|
||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "payment",
|
||||
icon: WalletOutlined,
|
||||
id: "payment",
|
||||
label: "支付管理",
|
||||
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||
},
|
||||
{
|
||||
code: "games",
|
||||
icon: SportsEsportsOutlined,
|
||||
@ -194,25 +221,22 @@ export const fallbackNavigation = [
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "host-org",
|
||||
icon: HubOutlined,
|
||||
id: "host-org",
|
||||
label: "团队管理",
|
||||
code: "system",
|
||||
icon: SettingsApplicationsOutlined,
|
||||
id: "system",
|
||||
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 }),
|
||||
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 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function mapBackendMenus(menus = []) {
|
||||
return relocateBackendMenus(menus)
|
||||
return orderTopLevelMenus(relocateBackendMenus(menus))
|
||||
.map((item) => {
|
||||
if (deprecatedMenuCodes.has(item.code)) {
|
||||
return null;
|
||||
@ -238,6 +262,17 @@ function relocateBackendMenus(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 = []) {
|
||||
const appConfigMenu = menus.find((item) => item.code === "app-config");
|
||||
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";
|
||||
|
||||
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", () => {
|
||||
const items = filterNavigationByPermission(
|
||||
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",
|
||||
firstRechargeRewardView: "first-recharge-reward:view",
|
||||
firstRechargeRewardUpdate: "first-recharge-reward:update",
|
||||
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
|
||||
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
|
||||
sevenDayCheckInView: "seven-day-checkin:view",
|
||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||
luckyGiftView: "lucky-gift:view",
|
||||
luckyGiftUpdate: "lucky-gift:update",
|
||||
roomRocketView: "room-rocket:view",
|
||||
roomRocketUpdate: "room-rocket:update",
|
||||
roomTurnoverRewardView: "room-turnover-reward:view",
|
||||
roomTurnoverRewardUpdate: "room-turnover-reward:update",
|
||||
roomTurnoverRewardRetry: "room-turnover-reward:retry",
|
||||
userLeaderboardView: "user-leaderboard:view",
|
||||
redPacketView: "red-packet:view",
|
||||
redPacketUpdate: "red-packet:update",
|
||||
vipConfigView: "vip-config:view",
|
||||
vipConfigUpdate: "vip-config:update",
|
||||
vipConfigGrant: "vip-config:grant",
|
||||
weeklyStarView: "weekly-star:view",
|
||||
weeklyStarCreate: "weekly-star:create",
|
||||
weeklyStarUpdate: "weekly-star:update",
|
||||
weeklyStarSettle: "weekly-star:settle",
|
||||
uploadCreate: "upload:create",
|
||||
} as const;
|
||||
|
||||
@ -185,12 +194,15 @@ export const MENU_CODES = {
|
||||
dailyTaskList: "daily-task-list",
|
||||
registrationReward: "registration-reward",
|
||||
firstRechargeReward: "first-recharge-reward",
|
||||
cumulativeRechargeReward: "cumulative-recharge-reward",
|
||||
achievementConfig: "achievement-config",
|
||||
sevenDayCheckIn: "seven-day-checkin",
|
||||
roomRocket: "room-rocket",
|
||||
roomTurnoverReward: "room-turnover-reward",
|
||||
userLeaderboard: "user-leaderboard",
|
||||
redPacket: "red-packet",
|
||||
vipConfig: "vip-config",
|
||||
weeklyStar: "weekly-star",
|
||||
geo: "geo",
|
||||
hostOrg: "host-org",
|
||||
hostOrgCountries: "host-org-countries",
|
||||
|
||||
@ -2,6 +2,7 @@ import { authRoutes } from "@/features/auth/routes.js";
|
||||
import { achievementRoutes } from "@/features/achievements/routes.js";
|
||||
import { appConfigRoutes } from "@/features/app-config/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 { dailyTaskRoutes } from "@/features/daily-tasks/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 { roomRoutes } from "@/features/rooms/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 { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
|
||||
import { usersRoutes } from "@/features/users/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 { AdminRoute, PublicRoute } from "./types";
|
||||
|
||||
@ -41,8 +44,11 @@ export const adminRoutes: AdminRoute[] = [
|
||||
...achievementRoutes,
|
||||
...registrationRewardRoutes,
|
||||
...firstRechargeRewardRoutes,
|
||||
...cumulativeRechargeRewardRoutes,
|
||||
...sevenDayCheckInRoutes,
|
||||
...roomRocketRoutes,
|
||||
...roomTurnoverRewardRoutes,
|
||||
...weeklyStarRoutes,
|
||||
...userLeaderboardRoutes,
|
||||
...redPacketRoutes,
|
||||
...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);
|
||||
}
|
||||
|
||||
.toolbarLeft {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.selectedMeta {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.timeFilter {
|
||||
flex: 0 1 280px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.selectColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -244,13 +258,15 @@
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar,
|
||||
.toolbarLeft,
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search,
|
||||
.statusSelect {
|
||||
.statusSelect,
|
||||
.timeFilter {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
|
||||
@ -19,8 +20,12 @@ export function useAppUsersPage() {
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [country, setCountry] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
const [sortBy, setSortBy] = useState("created_at");
|
||||
const [sortDirection, setSortDirection] = useState("desc");
|
||||
const [page, setPage] = useState(1);
|
||||
@ -34,12 +39,16 @@ export function useAppUsersPage() {
|
||||
const [patchedUsers, setPatchedUsers] = useState({});
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
country,
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
sort_by: sortBy,
|
||||
sort_direction: sortDirection,
|
||||
end_ms: timeRange.endMs,
|
||||
status,
|
||||
start_ms: timeRange.startMs,
|
||||
}),
|
||||
[query, sortBy, sortDirection, status],
|
||||
[country, query, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs],
|
||||
);
|
||||
const {
|
||||
data: queryData = emptyData,
|
||||
@ -65,7 +74,24 @@ export function useAppUsersPage() {
|
||||
useEffect(() => {
|
||||
setSelectedUserIds([]);
|
||||
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) => {
|
||||
setQuery(value);
|
||||
@ -77,6 +103,26 @@ export function useAppUsersPage() {
|
||||
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) => {
|
||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||
setSortBy(field);
|
||||
@ -84,8 +130,11 @@ export function useAppUsersPage() {
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setCountry("");
|
||||
setQuery("");
|
||||
setRegionId("");
|
||||
setStatus("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -243,6 +292,7 @@ export function useAppUsersPage() {
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
changeLocationFilter,
|
||||
countryForm,
|
||||
countryOptions,
|
||||
data,
|
||||
@ -251,12 +301,17 @@ export function useAppUsersPage() {
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
loadingRegions,
|
||||
locationFilterOptions,
|
||||
locationFilterValue,
|
||||
openCountry,
|
||||
openEdit,
|
||||
openPassword,
|
||||
page,
|
||||
passwordForm,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
selectedUserIds,
|
||||
@ -268,7 +323,9 @@ export function useAppUsersPage() {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
status,
|
||||
timeRange,
|
||||
changeSort,
|
||||
changeTimeRange,
|
||||
submitCountry,
|
||||
submitEdit,
|
||||
submitPassword,
|
||||
|
||||
@ -17,6 +17,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||
@ -95,6 +96,14 @@ export function AppUserListPage() {
|
||||
key: "location",
|
||||
label: "国家 / 区域",
|
||||
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} />,
|
||||
},
|
||||
{
|
||||
@ -157,9 +166,19 @@ export function AppUserListPage() {
|
||||
<div className={styles.contentPanel}>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
{page.abilities.canStatus ? (
|
||||
<div className={styles.toolbar}>
|
||||
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} 个</span>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
{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
|
||||
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
||||
startIcon={<BlockOutlined fontSize="small" />}
|
||||
@ -168,8 +187,8 @@ export function AppUserListPage() {
|
||||
>
|
||||
批量封禁
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
min-height: 304px;
|
||||
padding: var(--space-5);
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { DataState } from "@/shared/ui/DataState.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 { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
|
||||
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
|
||||
|
||||
export function OverviewPage() {
|
||||
const page = useDashboardPage();
|
||||
const { menus = [] } = useOutletContext() || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -18,6 +21,7 @@ export function OverviewPage() {
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<DashboardStats overview={page.overview} stats={page.stats} />
|
||||
<DashboardFrequentMenus menus={menus} />
|
||||
<DashboardCharts overview={page.overview} />
|
||||
</DataState>
|
||||
</>
|
||||
|
||||
@ -8,7 +8,7 @@ export interface GamePlatformDto {
|
||||
platformName: string;
|
||||
status: 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;
|
||||
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||
callbackSecret?: string;
|
||||
|
||||
@ -37,7 +37,7 @@ const defaultPlatformForm = {
|
||||
platformName: "",
|
||||
status: "active",
|
||||
apiBaseUrl: "",
|
||||
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
|
||||
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||
adapterType: "demo",
|
||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||
callbackSecret: "",
|
||||
|
||||
@ -49,6 +49,7 @@ const adapterTypeOptions = [
|
||||
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||
["zeeone_v1", "ZeeOne V1"],
|
||||
["baishun_v1", "百顺 BAISHUN V1"],
|
||||
["vivagames_v1", "VIVAGAMES V1"],
|
||||
];
|
||||
|
||||
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 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 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({
|
||||
commandId: "bd-leader-test",
|
||||
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(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
|
||||
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(coinSellerInit?.method).toBe("GET");
|
||||
expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
|
||||
|
||||
@ -50,6 +50,8 @@ export function useHostCoinSellersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDirection, setSortDirection] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
@ -60,16 +62,20 @@ export function useHostCoinSellersPage() {
|
||||
const [loadingRates, setLoadingRates] = useState(false);
|
||||
const [selectedSeller, setSelectedSeller] = useState(null);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [contactSavingIds, setContactSavingIds] = useState({});
|
||||
const [sellerRowPatches, setSellerRowPatches] = useState({});
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
sort_by: sortBy,
|
||||
sort_direction: sortDirection,
|
||||
status,
|
||||
}),
|
||||
[query, regionId, status],
|
||||
[query, regionId, sortBy, sortDirection, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
data: queryData = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
@ -81,6 +87,16 @@ export function useHostCoinSellersPage() {
|
||||
pageSize,
|
||||
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) => {
|
||||
setQuery(value);
|
||||
@ -97,10 +113,18 @@ export function useHostCoinSellersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeSort = (field) => {
|
||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||
setSortBy(field);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setSortBy("");
|
||||
setSortDirection("");
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
if (!selectedSeller?.userId) {
|
||||
@ -304,8 +366,10 @@ export function useHostCoinSellersPage() {
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeSort,
|
||||
changeStatus,
|
||||
closeAction,
|
||||
contactSavingIds,
|
||||
createForm,
|
||||
data,
|
||||
editForm,
|
||||
@ -330,6 +394,7 @@ export function useHostCoinSellersPage() {
|
||||
changeRateRegionId,
|
||||
removeRateTier,
|
||||
resetFilters,
|
||||
saveSellerContact,
|
||||
selectedSeller,
|
||||
setCreateForm,
|
||||
setEditForm,
|
||||
@ -337,6 +402,8 @@ export function useHostCoinSellersPage() {
|
||||
setStockForm,
|
||||
status,
|
||||
stockForm,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
submitRateSettings,
|
||||
submitCreateSeller,
|
||||
submitEditSeller,
|
||||
|
||||
@ -110,6 +110,15 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ledgerDrawer.ledgerDrawer {
|
||||
width: min(960px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.ledgerDrawerBody.ledgerDrawerBody {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rateTierHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
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 MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
@ -16,6 +19,7 @@ import {
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
|
||||
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} />,
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "merchantAssetType",
|
||||
label: "资产类型",
|
||||
render: (item) => item.merchantAssetType || "-",
|
||||
width: "minmax(150px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "merchantBalance",
|
||||
label: "币商余额",
|
||||
sortable: true,
|
||||
sortField: "merchant_balance",
|
||||
render: (item) => formatNumber(item.merchantBalance),
|
||||
width: "minmax(120px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "createdByUserId",
|
||||
label: "创建人 ID",
|
||||
render: (item) => item.createdByUserId || "-",
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
key: "totalRechargeUsdt",
|
||||
label: "累充USDT",
|
||||
sortable: true,
|
||||
sortField: "total_recharge_usdt",
|
||||
render: (item) => formatUSDTMicro(item.totalRechargeUsdtMicro),
|
||||
width: "minmax(130px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
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;
|
||||
});
|
||||
const regionFilter = createRegionColumnFilter({
|
||||
@ -152,7 +168,7 @@ export function HostCoinSellersPage() {
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1040px"
|
||||
minWidth="930px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -204,7 +220,7 @@ export function HostCoinSellersPage() {
|
||||
onSubmit={page.submitEditSeller}
|
||||
title="编辑联系方式"
|
||||
>
|
||||
<TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} />
|
||||
<TextField disabled label="用户 ID" required value={page.editForm.targetUserId} />
|
||||
<TextField
|
||||
disabled={updateDisabled}
|
||||
label="联系方式"
|
||||
@ -290,6 +306,8 @@ export function HostCoinSellersPage() {
|
||||
</HostOrgActionModal>
|
||||
|
||||
<SideDrawer
|
||||
className={styles.ledgerDrawer}
|
||||
contentClassName={styles.ledgerDrawerBody}
|
||||
open={page.activeAction === "ledger"}
|
||||
title={`币商流水 - ${sellerDrawerTitle(page.selectedSeller)}`}
|
||||
width="wide"
|
||||
@ -420,14 +438,21 @@ function SellerStatusSwitch({ item, page }) {
|
||||
|
||||
function SellerContact({ item, page }) {
|
||||
const contact = String(item.contact || "").trim();
|
||||
if (!page?.abilities.canUpdate) {
|
||||
return <span className={contact ? styles.inlineValue : styles.contactPlaceholder}>{contact || "未填写"}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={styles.contactButton} type="button" onClick={() => page.openEditSeller(item)}>
|
||||
{contact || "填写联系方式"}
|
||||
</button>
|
||||
<InlineEditableCell
|
||||
buttonTitle="点击编辑联系方式"
|
||||
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");
|
||||
}
|
||||
|
||||
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 = {
|
||||
borderColor: "rgba(37, 99, 235, 0.28)",
|
||||
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" }));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const seller = sellerFixture();
|
||||
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ activeAction: "ledger", selectedSeller: seller }));
|
||||
@ -59,8 +101,10 @@ function pageFixture(patch = {}) {
|
||||
changeQuery: vi.fn(),
|
||||
changeRateRegionId: vi.fn(),
|
||||
changeRegionId: vi.fn(),
|
||||
changeSort: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
closeAction: vi.fn(),
|
||||
contactSavingIds: {},
|
||||
createForm: { contact: "", targetUserId: "" },
|
||||
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
editForm: { contact: "", targetUserId: "" },
|
||||
@ -83,6 +127,7 @@ function pageFixture(patch = {}) {
|
||||
reload: vi.fn(),
|
||||
removeRateTier: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
saveSellerContact: vi.fn(),
|
||||
selectedSeller: null,
|
||||
setCreateForm: vi.fn(),
|
||||
setEditForm: vi.fn(),
|
||||
@ -90,6 +135,8 @@ function pageFixture(patch = {}) {
|
||||
setStockForm: vi.fn(),
|
||||
status: "",
|
||||
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
||||
sortBy: "",
|
||||
sortDirection: "",
|
||||
submitCreateSeller: vi.fn(),
|
||||
submitEditSeller: vi.fn(),
|
||||
submitRateSettings: vi.fn(),
|
||||
@ -109,6 +156,7 @@ function sellerFixture() {
|
||||
merchantBalance: 9400000,
|
||||
regionId: 1,
|
||||
status: "active",
|
||||
totalRechargeUsdtMicro: 12500000,
|
||||
updatedAtMs: 1760000000000,
|
||||
userId: "3001",
|
||||
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 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(
|
||||
(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@ -90,7 +99,7 @@ export const createCoinSellerSchema = commandContactSchema.extend({
|
||||
|
||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||
status: z.enum(["active", "disabled"]),
|
||||
targetUserId: userIdSchema,
|
||||
targetUserId: entityIdSchema,
|
||||
});
|
||||
|
||||
export const coinSellerStockCreditSchema = z
|
||||
@ -121,11 +130,11 @@ export const createAgencySchema = commandBaseSchema.extend({
|
||||
});
|
||||
|
||||
export const agencyCloseSchema = commandBaseSchema.extend({
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
||||
agencyId: entityIdSchema,
|
||||
});
|
||||
|
||||
export const agencyJoinEnabledSchema = commandBaseSchema.extend({
|
||||
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
|
||||
agencyId: entityIdSchema,
|
||||
joinEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
createCoinAdjustment,
|
||||
exportCoinSellerLedger,
|
||||
getGiftDiamondRatios,
|
||||
listCoinAdjustments,
|
||||
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(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>> {
|
||||
const endpoint = API_ENDPOINTS.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 { 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 { downloadCsv } from "@/shared/api/download";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const pageSize = 50;
|
||||
@ -60,6 +64,12 @@ const baseColumns = [
|
||||
render: (entry) => <UserCell user={entry.receiver} />,
|
||||
width: "minmax(240px, 1.2fr)",
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
label: "操作人",
|
||||
render: (entry) => <OperatorCell entry={entry} />,
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "sellerBalanceAfter",
|
||||
label: "币商余额",
|
||||
@ -77,9 +87,11 @@ const baseColumns = [
|
||||
export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }) {
|
||||
const fixedSellerUserId = String(sellerUserId || lockedSeller?.userId || "").trim();
|
||||
const [page, setPage] = useState(1);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [sellerKeyword, setSellerKeyword] = useState("");
|
||||
const [ledgerType, setLedgerType] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
const { showToast } = useToast();
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
@ -102,6 +114,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const hasActiveFilters = Boolean(sellerKeyword || ledgerType || timeRange.startMs || timeRange.endMs);
|
||||
|
||||
const changeSellerKeyword = (value) => {
|
||||
setSellerKeyword(value);
|
||||
@ -121,44 +134,65 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
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) => {
|
||||
if (column.key === "seller" && !fixedSellerUserId) {
|
||||
const tableColumns = baseColumns
|
||||
.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 {
|
||||
...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 (
|
||||
<>
|
||||
<div className={styles.ledgerTableShell}>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton
|
||||
disabled={!sellerKeyword && !ledgerType && !timeRange.startMs && !timeRange.endMs}
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
|
||||
{exporting ? "导出中" : "导出"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
@ -166,7 +200,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
||||
columns={tableColumns}
|
||||
emptyLabel="当前无币商流水"
|
||||
items={items}
|
||||
minWidth="1030px"
|
||||
minWidth={fixedSellerUserId ? "890px" : "1160px"}
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -181,7 +215,22 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
||||
/>
|
||||
</AdminListBody>
|
||||
</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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RegistrationRewardClaimPageDto extends ApiPage<RegistrationRewardClaimDto> {
|
||||
todayClaimedCount: number;
|
||||
}
|
||||
|
||||
type RawConfig = RegistrationRewardConfigDto & Record<string, unknown>;
|
||||
type RawClaim = RegistrationRewardClaimDto & Record<string, unknown>;
|
||||
type RawClaimPage = ApiPage<RawClaim> & Record<string, unknown>;
|
||||
|
||||
export function getRegistrationRewardConfig(): Promise<RegistrationRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getRegistrationRewardConfig;
|
||||
@ -71,14 +76,15 @@ export function updateRegistrationRewardConfig(
|
||||
).then(normalizeConfig);
|
||||
}
|
||||
|
||||
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<ApiPage<RegistrationRewardClaimDto>> {
|
||||
export function listRegistrationRewardClaims(query: PageQuery = {}): Promise<RegistrationRewardClaimPageDto> {
|
||||
const endpoint = API_ENDPOINTS.listRegistrationRewardClaims;
|
||||
return apiRequest<ApiPage<RawClaim>>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
|
||||
return apiRequest<RawClaimPage>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
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 { Button } from "@/shared/ui/Button.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";
|
||||
|
||||
export function RegistrationRewardConfigSummary({
|
||||
@ -10,7 +11,10 @@ export function RegistrationRewardConfigSummary({
|
||||
configLoading,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
onTimeRangeChange,
|
||||
resourceGroups,
|
||||
timeRange,
|
||||
todayClaimedCount,
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.summaryPanel}>
|
||||
@ -21,10 +25,12 @@ export function RegistrationRewardConfigSummary({
|
||||
<SummaryItem label="发放状态">
|
||||
<ConfigStatus config={config} loading={configLoading} />
|
||||
</SummaryItem>
|
||||
<SummaryItem label="今日已领">{formatNumber(todayClaimedCount)} 份</SummaryItem>
|
||||
<SummaryItem label="每日限制">{config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||
<SummaryItem label="奖励">{config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}</SummaryItem>
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<TimeRangeFilter label="领取时间" value={timeRange} onChange={onTimeRangeChange} />
|
||||
<AdminActionIconButton disabled={configLoading} label="刷新配置" type="button" onClick={onRefresh}>
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
|
||||
@ -10,7 +10,8 @@ import {
|
||||
import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js";
|
||||
|
||||
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 = {
|
||||
coinAmount: "",
|
||||
@ -31,8 +32,17 @@ export function useRegistrationRewardPage() {
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [timeRange, setTimeRange] = useState(emptyTimeRange);
|
||||
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 {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
@ -94,9 +104,15 @@ export function useRegistrationRewardPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeTimeRange = (value) => {
|
||||
setTimeRange(value || emptyTimeRange);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setTimeRange(emptyTimeRange);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -124,6 +140,7 @@ export function useRegistrationRewardPage() {
|
||||
abilities,
|
||||
changeQuery,
|
||||
changeStatus,
|
||||
changeTimeRange,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
@ -144,6 +161,7 @@ export function useRegistrationRewardPage() {
|
||||
setPage,
|
||||
status,
|
||||
submitConfig,
|
||||
timeRange,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -44,12 +44,7 @@ const columnsBase = [
|
||||
key: "claimId",
|
||||
label: "领取记录",
|
||||
width: "minmax(240px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.claimId}</span>
|
||||
<span className={styles.meta}>{claim.rewardDay || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
render: (claim) => claim.claimId || "-",
|
||||
},
|
||||
{
|
||||
key: "failure",
|
||||
@ -94,8 +89,11 @@ export function RegistrationRewardPage() {
|
||||
config={page.config}
|
||||
configLoading={page.configLoading}
|
||||
resourceGroups={page.resourceGroups}
|
||||
timeRange={page.timeRange}
|
||||
todayClaimedCount={page.claims.todayClaimedCount || 0}
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
onTimeRangeChange={page.changeTimeRange}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<AdminListBody>
|
||||
@ -134,12 +132,14 @@ export function RegistrationRewardPage() {
|
||||
|
||||
function ClaimUser({ claim }) {
|
||||
const user = claim.user || {};
|
||||
const name = user.username || `用户 ${claim.userId}`;
|
||||
const displayUserId = user.displayUserId || "";
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
|
||||
<span className={styles.meta}>{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}</span>
|
||||
<span className={styles.name}>{displayUserId ? `${name} (${displayUserId})` : name}</span>
|
||||
{displayUserId ? null : <span className={styles.meta}>ID {claim.userId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -157,7 +157,6 @@ function RewardSummary({ claim }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{formatNumber(claim.coinAmount)} 金币</span>
|
||||
<span className={styles.meta}>注册奖励</span>
|
||||
</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 { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
|
||||
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
@ -279,69 +280,18 @@ function GiftStatusSwitch({ gift, page }) {
|
||||
function GiftSortOrderCell({ gift, page }) {
|
||||
const originalValue = String(gift.sortOrder ?? 0);
|
||||
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 (
|
||||
<TextField
|
||||
autoFocus
|
||||
className={styles.inlineSortInput}
|
||||
disabled={saving}
|
||||
<InlineEditableCell
|
||||
buttonTitle="点击编辑排序"
|
||||
editable={page.abilities.canUpdateGift}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
size="small"
|
||||
maxWidth="76px"
|
||||
normalizeValue={(value) => String(value || "0").trim() || "0"}
|
||||
saving={saving}
|
||||
type="number"
|
||||
value={value}
|
||||
onBlur={save}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
value={originalValue}
|
||||
width="76px"
|
||||
onSave={(nextValue) => page.saveGiftSortOrder(gift, nextValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -76,51 +76,6 @@
|
||||
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 {
|
||||
display: grid;
|
||||
width: 320px;
|
||||
|
||||
@ -17,7 +17,7 @@ export function RoleFormDrawer({
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
@ -28,9 +28,9 @@ export function RoleFormDrawer({
|
||||
</div>
|
||||
</section>
|
||||
{abilities.canLoadPermissions ? (
|
||||
<section className="form-drawer__section">
|
||||
<section className="form-drawer__section role-permission-scope">
|
||||
<div className="form-drawer__section-title">权限范围</div>
|
||||
<div className="permission-menu-list">
|
||||
<div className="permission-menu-list role-permission-menu-list">
|
||||
{permissionGroups.map((group) => (
|
||||
<PermissionMenuGroup
|
||||
abilities={abilities}
|
||||
|
||||
@ -18,22 +18,86 @@ const emptyRole = { code: "", description: "", name: "", permissionIds: [] };
|
||||
const emptyData = { permissions: [], roles: [] };
|
||||
const permissionGroupDefinitions = [
|
||||
{ key: "overview", prefixes: ["overview"], title: "总览" },
|
||||
{ key: "users", prefixes: ["user"], title: "用户管理" },
|
||||
{ key: "users", prefixes: ["user"], title: "后台用户" },
|
||||
{ key: "roles", prefixes: ["role"], title: "角色配置" },
|
||||
{ key: "menus", prefixes: ["menu", "permission"], title: "菜单权限" },
|
||||
{ key: "app-users", prefixes: ["app-user"], title: "App 用户" },
|
||||
{ key: "rooms", prefixes: ["room"], title: "房间管理" },
|
||||
{ key: "app-users", prefixes: ["app-user", "level-config", "region-block"], title: "APP 用户" },
|
||||
{ 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: "regions", prefixes: ["region"], title: "区域管理" },
|
||||
{ key: "agencies", prefixes: ["agency"], title: "Agency 列表" },
|
||||
{ key: "bds", prefixes: ["bd"], title: "BD 列表" },
|
||||
{ key: "hosts", prefixes: ["host"], title: "Host 列表" },
|
||||
{ key: "coin-sellers", prefixes: ["coin-seller"], title: "Coin Saller列表" },
|
||||
{
|
||||
key: "host-org",
|
||||
prefixes: [
|
||||
"agency",
|
||||
"bd",
|
||||
"coin-seller",
|
||||
"host",
|
||||
"host-agency-policy",
|
||||
"host-salary-settlement",
|
||||
"team-salary-policy",
|
||||
],
|
||||
title: "团队管理",
|
||||
},
|
||||
{ key: "logs", prefixes: ["log"], title: "日志审计" },
|
||||
{ key: "common", prefixes: ["upload"], title: "通用能力" },
|
||||
];
|
||||
const fallbackPermissionGroup = { key: "other", title: "其他权限" };
|
||||
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() {
|
||||
const [query, setQuery] = useState("");
|
||||
@ -244,5 +308,18 @@ function comparePermissions(left, right) {
|
||||
if (kindDiff !== 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
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",
|
||||
createTier: "createTier",
|
||||
createUser: "createUser",
|
||||
createWeeklyStarCycle: "createWeeklyStarCycle",
|
||||
creditCoinSellerStock: "creditCoinSellerStock",
|
||||
dashboardOverview: "dashboardOverview",
|
||||
deleteAgency: "deleteAgency",
|
||||
@ -88,6 +89,7 @@ export const API_OPERATIONS = {
|
||||
exportUsers: "exportUsers",
|
||||
getCoinSellerSalaryRates: "getCoinSellerSalaryRates",
|
||||
getCountry: "getCountry",
|
||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
|
||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||
@ -100,8 +102,10 @@ export const API_OPERATIONS = {
|
||||
getRoleDataScopes: "getRoleDataScopes",
|
||||
getRoomConfig: "getRoomConfig",
|
||||
getRoomRocketConfig: "getRoomRocketConfig",
|
||||
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||
getUser: "getUser",
|
||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||
grantResource: "grantResource",
|
||||
grantResourceGroup: "grantResourceGroup",
|
||||
listAchievementDefinitions: "listAchievementDefinitions",
|
||||
@ -117,6 +121,7 @@ export const API_OPERATIONS = {
|
||||
listCoinSellerLedger: "listCoinSellerLedger",
|
||||
listCoinSellers: "listCoinSellers",
|
||||
listCountries: "listCountries",
|
||||
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listExploreTabs: "listExploreTabs",
|
||||
@ -148,12 +153,16 @@ export const API_OPERATIONS = {
|
||||
listRoles: "listRoles",
|
||||
listRoomPins: "listRoomPins",
|
||||
listRooms: "listRooms",
|
||||
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
|
||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||
listSystemMenus: "listSystemMenus",
|
||||
listTaskDefinitions: "listTaskDefinitions",
|
||||
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||
listUserLeaderboards: "listUserLeaderboards",
|
||||
listUsers: "listUsers",
|
||||
listWeeklyStarCycles: "listWeeklyStarCycles",
|
||||
listWeeklyStarLeaderboard: "listWeeklyStarLeaderboard",
|
||||
listWeeklyStarSettlements: "listWeeklyStarSettlements",
|
||||
login: "login",
|
||||
logout: "logout",
|
||||
lookupCoinAdjustmentTarget: "lookupCoinAdjustmentTarget",
|
||||
@ -169,6 +178,7 @@ export const API_OPERATIONS = {
|
||||
replaceRolePermissions: "replaceRolePermissions",
|
||||
resetUserPassword: "resetUserPassword",
|
||||
retryRedPacketRefund: "retryRedPacketRefund",
|
||||
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
||||
search: "search",
|
||||
setAgencyJoinEnabled: "setAgencyJoinEnabled",
|
||||
setBDLeaderStatus: "setBDLeaderStatus",
|
||||
@ -176,6 +186,7 @@ export const API_OPERATIONS = {
|
||||
setCoinSellerStatus: "setCoinSellerStatus",
|
||||
setGameStatus: "setGameStatus",
|
||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||
setWeeklyStarCycleStatus: "setWeeklyStarCycleStatus",
|
||||
sortMenus: "sortMenus",
|
||||
syncPermissions: "syncPermissions",
|
||||
updateAchievementDefinition: "updateAchievementDefinition",
|
||||
@ -183,6 +194,7 @@ export const API_OPERATIONS = {
|
||||
updateBanner: "updateBanner",
|
||||
updateCatalog: "updateCatalog",
|
||||
updateCountry: "updateCountry",
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||
updateGift: "updateGift",
|
||||
@ -206,6 +218,7 @@ export const API_OPERATIONS = {
|
||||
updateRoom: "updateRoom",
|
||||
updateRoomConfig: "updateRoomConfig",
|
||||
updateRoomRocketConfig: "updateRoomRocketConfig",
|
||||
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
|
||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||
updateTaskDefinition: "updateTaskDefinition",
|
||||
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
||||
@ -213,6 +226,7 @@ export const API_OPERATIONS = {
|
||||
updateTrack: "updateTrack",
|
||||
updateUser: "updateUser",
|
||||
updateUserStatus: "updateUserStatus",
|
||||
updateWeeklyStarCycle: "updateWeeklyStarCycle",
|
||||
uploadFile: "uploadFile",
|
||||
uploadImage: "uploadImage",
|
||||
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
||||
@ -508,6 +522,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.creditCoinSellerStock,
|
||||
@ -746,6 +767,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getFirstRechargeRewardConfig,
|
||||
@ -830,6 +858,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getSevenDayCheckInConfig,
|
||||
@ -844,6 +879,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.grantResource,
|
||||
@ -947,6 +989,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listEmojiPackCategories,
|
||||
@ -1164,6 +1213,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSevenDayCheckInClaims,
|
||||
@ -1206,6 +1262,27 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.login,
|
||||
@ -1301,6 +1378,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.search,
|
||||
@ -1348,6 +1432,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.sortMenus,
|
||||
@ -1397,6 +1488,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateExploreTab,
|
||||
@ -1558,6 +1656,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateSevenDayCheckInConfig,
|
||||
@ -1607,6 +1712,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -276,6 +404,38 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2994,7 +3154,7 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegistrationRewardClaims: {
|
||||
listCumulativeRechargeRewardGrants: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@ -3006,6 +3166,133 @@ export interface operations {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3030,6 +3317,54 @@ export interface operations {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -16,6 +16,8 @@ export interface PageQuery {
|
||||
[key: string]: boolean | number | string | null | undefined;
|
||||
agency_id?: EntityId;
|
||||
agencyId?: EntityId;
|
||||
claimed_end_ms?: number | string;
|
||||
claimed_start_ms?: number | string;
|
||||
enabled?: boolean;
|
||||
displayScope?: string;
|
||||
keyword?: string;
|
||||
@ -222,6 +224,8 @@ export interface CoinSellerLedgerDto {
|
||||
entryId: number;
|
||||
ledgerType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
operator?: CoinAdjustmentOperatorDto;
|
||||
operatorUserId?: string;
|
||||
receiver: CoinLedgerUserDto;
|
||||
seller: CoinLedgerUserDto;
|
||||
sellerBalanceAfter: number;
|
||||
@ -468,6 +472,7 @@ export interface CoinSellerDto {
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
status?: string;
|
||||
totalRechargeUsdtMicro?: number;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
|
||||
@ -72,5 +72,34 @@ function itemKey(item, index) {
|
||||
if (!item || typeof item !== "object") {
|
||||
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 {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
overflow-x: hidden;
|
||||
@ -520,6 +520,7 @@
|
||||
}
|
||||
|
||||
.nav-item--parent {
|
||||
height: 36px;
|
||||
padding-right: var(--space-3);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
.kpi-grid,
|
||||
.panel-grid,
|
||||
.overview-charts,
|
||||
.frequent-menu-grid,
|
||||
.user-kpi-row,
|
||||
.page-skeleton__kpis,
|
||||
.page-skeleton__charts {
|
||||
@ -101,6 +102,7 @@
|
||||
.kpi-grid,
|
||||
.panel-grid,
|
||||
.overview-charts,
|
||||
.frequent-menu-grid,
|
||||
.user-kpi-row,
|
||||
.alert-strip,
|
||||
.page-skeleton__kpis,
|
||||
|
||||
@ -805,6 +805,32 @@
|
||||
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 {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--text-primary);
|
||||
@ -917,6 +943,18 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@ -995,6 +1033,40 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@ -1272,6 +1344,85 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user