活动相关配置

This commit is contained in:
zhx 2026-06-06 13:42:26 +08:00
parent 945e2e7047
commit fd80e18aff
74 changed files with 5494 additions and 305 deletions

View File

@ -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",

View File

@ -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;

View 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();
}
});
}

View File

@ -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");
}

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View 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));
});

View File

@ -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>

View File

@ -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");

View File

@ -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,

View 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;
}
}

View 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)),
};
}

View File

@ -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",

View File

@ -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,

View 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");
});

View File

@ -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%;
}

View File

@ -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,

View File

@ -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}

View 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;
}

View File

@ -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>
);
}

View File

@ -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)}`;
}

View File

@ -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;
}
}

View File

@ -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,
};
}

View File

@ -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));
}

View 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),
};
}

View 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,
},
];

View 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>
);
}

View File

@ -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);

View File

@ -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>
</>

View File

@ -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;

View File

@ -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: "",

View File

@ -49,6 +49,7 @@ const adapterTypeOptions = [
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
["baishun_v1", "百顺 BAISHUN V1"],
["vivagames_v1", "VIVAGAMES V1"],
];
const baseColumns = [

View File

@ -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");

View File

@ -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,

View File

@ -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;

View File

@ -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)",

View File

@ -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",

View 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);
}
});

View File

@ -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),
});

View File

@ -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");
});

View File

@ -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), {

View File

@ -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>
);
}

View File

@ -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);
}

View 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" },
});
});

View File

@ -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),
}));
}

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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: "" },
};
}

View File

@ -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)}
/>
);
}

View File

@ -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;

View File

@ -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}

View File

@ -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;
}

View 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;
}

View File

@ -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>
);
}

View File

@ -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));
}

View File

@ -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,
};
}

View File

@ -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));
}

View 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),
};
}

View File

@ -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;
}
}

View 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,
},
];

View 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;
}

View 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));
}

View 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,
},
];

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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"
];

View 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"]);
});

View 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>
);
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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;