From fd80e18aff04ec9a80192dbb8d1f8b164a942ec4 Mon Sep 17 00:00:00 2001 From: zhx Date: Sat, 6 Jun 2026 13:42:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E7=9B=B8=E5=85=B3=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 206 ++++++ databi/src/DatabiApp.jsx | 78 ++- databi/src/DatabiApp.test.jsx | 62 ++ databi/src/components/DatabiHeader.jsx | 57 +- databi/src/data/createDashboardModel.js | 21 +- databi/src/styles/layout.css | 22 +- databi/src/utils/time.js | 138 +++- databi/src/utils/time.test.js | 16 + src/app/layout/AdminLayout.jsx | 11 +- src/app/navigation/menu.js | 101 ++- src/app/navigation/menu.test.jsx | 29 + src/app/navigation/menuUsage.js | 124 ++++ src/app/navigation/menuUsage.test.js | 69 ++ src/app/permissions.ts | 12 + src/app/router/routeConfig.ts | 6 + src/features/app-users/api.test.ts | 35 + src/features/app-users/app-users.module.css | 18 +- .../app-users/hooks/useAppUsersPage.js | 61 +- .../app-users/pages/AppUserListPage.jsx | 29 +- .../cumulative-recharge-reward/api.ts | 176 +++++ .../CumulativeRechargeRewardConfigDrawer.jsx | 176 +++++ .../CumulativeRechargeRewardConfigSummary.jsx | 97 +++ .../cumulative-recharge-reward.module.css | 159 +++++ .../hooks/useCumulativeRechargeRewardPage.js | 190 +++++ .../pages/CumulativeRechargeRewardPage.jsx | 209 ++++++ .../cumulative-recharge-reward/permissions.js | 11 + .../cumulative-recharge-reward/routes.js | 13 + .../components/DashboardFrequentMenus.jsx | 46 ++ src/features/dashboard/dashboard.css | 99 +++ src/features/dashboard/pages/OverviewPage.jsx | 4 + src/features/games/api.ts | 2 +- src/features/games/hooks/useGamesPage.js | 2 +- src/features/games/pages/GameListPage.jsx | 1 + src/features/host-org/api.test.ts | 11 +- .../host-org/hooks/useHostCoinSellersPage.js | 71 +- src/features/host-org/host-org.module.css | 9 + .../host-org/pages/HostCoinSellersPage.jsx | 102 ++- .../pages/HostCoinSellersPage.test.jsx | 48 ++ src/features/host-org/schema.test.ts | 42 ++ src/features/host-org/schema.ts | 15 +- src/features/operations/api.test.ts | 22 + src/features/operations/api.ts | 8 + .../components/CoinSellerLedgerTable.jsx | 101 ++- src/features/operations/operations.module.css | 20 + src/features/registration-reward/api.test.ts | 60 ++ src/features/registration-reward/api.ts | 10 +- .../RegistrationRewardConfigSummary.jsx | 6 + .../hooks/useRegistrationRewardPage.js | 22 +- .../pages/RegistrationRewardPage.jsx | 17 +- .../pages/RegistrationRewardPage.test.jsx | 87 +++ src/features/resources/pages/GiftListPage.jsx | 72 +- src/features/resources/resources.module.css | 45 -- .../roles/components/RoleFormDrawer.jsx | 6 +- src/features/roles/hooks/useRolesPage.js | 91 ++- src/features/room-turnover-reward/api.ts | 168 +++++ .../RoomTurnoverRewardConfigDrawer.jsx | 178 +++++ .../RoomTurnoverRewardConfigSummary.jsx | 61 ++ .../hooks/useRoomTurnoverRewardPage.js | 216 ++++++ .../pages/RoomTurnoverRewardPage.jsx | 198 ++++++ .../room-turnover-reward/permissions.js | 12 + .../room-turnover-reward.module.css | 157 +++++ src/features/room-turnover-reward/routes.js | 12 + src/features/weekly-star/api.ts | 196 ++++++ .../weekly-star/pages/WeeklyStarPage.jsx | 656 ++++++++++++++++++ src/features/weekly-star/routes.js | 12 + src/shared/api/generated/endpoints.ts | 112 +++ src/shared/api/generated/schema.d.ts | 337 ++++++++- src/shared/api/types.ts | 5 + src/shared/hooks/usePaginatedQuery.js | 31 +- src/shared/hooks/usePaginatedQuery.test.js | 39 ++ src/shared/ui/InlineEditableCell.jsx | 108 +++ src/styles/layout.css | 3 +- src/styles/responsive.css | 2 + src/styles/shared-ui.css | 151 ++++ 74 files changed, 5494 insertions(+), 305 deletions(-) create mode 100644 databi/src/DatabiApp.test.jsx create mode 100644 databi/src/utils/time.test.js create mode 100644 src/app/navigation/menuUsage.js create mode 100644 src/app/navigation/menuUsage.test.js create mode 100644 src/features/app-users/api.test.ts create mode 100644 src/features/cumulative-recharge-reward/api.ts create mode 100644 src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx create mode 100644 src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx create mode 100644 src/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css create mode 100644 src/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js create mode 100644 src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx create mode 100644 src/features/cumulative-recharge-reward/permissions.js create mode 100644 src/features/cumulative-recharge-reward/routes.js create mode 100644 src/features/dashboard/components/DashboardFrequentMenus.jsx create mode 100644 src/features/host-org/schema.test.ts create mode 100644 src/features/registration-reward/api.test.ts create mode 100644 src/features/registration-reward/pages/RegistrationRewardPage.test.jsx create mode 100644 src/features/room-turnover-reward/api.ts create mode 100644 src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx create mode 100644 src/features/room-turnover-reward/components/RoomTurnoverRewardConfigSummary.jsx create mode 100644 src/features/room-turnover-reward/hooks/useRoomTurnoverRewardPage.js create mode 100644 src/features/room-turnover-reward/pages/RoomTurnoverRewardPage.jsx create mode 100644 src/features/room-turnover-reward/permissions.js create mode 100644 src/features/room-turnover-reward/room-turnover-reward.module.css create mode 100644 src/features/room-turnover-reward/routes.js create mode 100644 src/features/weekly-star/api.ts create mode 100644 src/features/weekly-star/pages/WeeklyStarPage.jsx create mode 100644 src/features/weekly-star/routes.js create mode 100644 src/shared/hooks/usePaginatedQuery.test.js create mode 100644 src/shared/ui/InlineEditableCell.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index bc7dc02..ada3104 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 9abaf81..e8b1c0a 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -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; diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx new file mode 100644 index 0000000..6300f21 --- /dev/null +++ b/databi/src/DatabiApp.test.jsx @@ -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: () =>
+})); + +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(); + + 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(); + } + }); +} diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx index db39bf9..86eac9c 100644 --- a/databi/src/components/DatabiHeader.jsx +++ b/databi/src/components/DatabiHeader.jsx @@ -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({
TZ} + isOpen={openControl === "timezone"} + label="时区" + onSelect={(value) => selectItem(onTimeZoneChange, value)} + onToggle={() => toggleControl("timezone")} + options={timeZoneOptions} + value={timeZone} + valueLabel={timeZoneLabel} + /> + APP} isOpen={openControl === "app"} label="App" @@ -93,6 +109,7 @@ export function DatabiHeader({ onSelect={(nextRange) => selectItem(onRangeChange, nextRange)} onToggle={() => toggleControl("range")} range={range} + timeZone={timeZone} /> } @@ -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 ( -
+
-
- ) : null} + ) : null} +
; +} + +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; +type RawTier = CumulativeRechargeRewardTierDto & Record; +type RawGrant = CumulativeRechargeRewardGrantDto & Record; + +export function getCumulativeRechargeRewardConfig(): Promise { + const endpoint = API_ENDPOINTS.getCumulativeRechargeRewardConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getCumulativeRechargeRewardConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateCumulativeRechargeRewardConfig( + payload: CumulativeRechargeRewardConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateCumulativeRechargeRewardConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateCumulativeRechargeRewardConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listCumulativeRechargeRewardGrants( + query: PageQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listCumulativeRechargeRewardGrants; + return apiRequest>(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; + 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; +} diff --git a/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx new file mode 100644 index 0000000..a0f85e9 --- /dev/null +++ b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx @@ -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 ( + +
+

累充奖励配置

+
+
发放状态
+ setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> +
+
+
+ 奖励档位 + +
+
+ {(form.tiers || []).map((tier, index) => ( +
+
+ 档位 {index + 1} + removeTier(index)} + > + + +
+
+ updateTier(index, { tierCode: event.target.value })} + /> + updateTier(index, { tierName: event.target.value })} + /> + updateTier(index, { thresholdUsd: event.target.value })} + /> + updateTier(index, { resourceGroupId: value })} + /> + updateTier(index, { status: event.target.value })} + > + {statusOptions.map(([value, label]) => ( + + {label} + + ))} + + updateTier(index, { sortOrder: event.target.value })} + /> +
+
+ ))} + {!form.tiers?.length ?
暂无档位
: null} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx new file mode 100644 index 0000000..4d2335b --- /dev/null +++ b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx @@ -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 ( +
+
+ + + {config?.enabled ? "启用" : "停用"} + + + + {activeTiers.length}/{tiers.length} + + {thresholdSummary(activeTiers)} + {resourceGroupSummary(tiers, resourceGroups)} +
+
+ + {canUpdate ? ( + + ) : null} +
+
+ ); +} + +function SummaryItem({ children, label }) { + return ( +
+ {label} + {children} +
+ ); +} + +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)}`; +} diff --git a/src/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css b/src/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css new file mode 100644 index 0000000..39dfc81 --- /dev/null +++ b/src/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css @@ -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; + } +} diff --git a/src/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js b/src/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js new file mode 100644 index 0000000..cabd61f --- /dev/null +++ b/src/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js @@ -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, + }; +} diff --git a/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx b/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx new file mode 100644 index 0000000..53fd867 --- /dev/null +++ b/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx @@ -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) => , + }, + { + key: "cycle", + label: "周期", + width: "minmax(150px, 0.7fr)", + render: (grant) => grant.cycleKey || "-", + }, + { + key: "tier", + label: "命中档位", + width: "minmax(190px, 0.85fr)", + render: (grant) => ( +
+ {grant.tierCode || "-"} + {formatUSD(grant.thresholdUsdMinor)} +
+ ), + }, + { + key: "amount", + label: "累计 / 本次", + width: "minmax(210px, 0.9fr)", + render: (grant) => ( +
+ {formatUSD(grant.reachedUsdMinor)} + 本次 {formatUSD(grant.qualifyingUsdMinor)} +
+ ), + }, + { + key: "source", + label: "来源", + width: "minmax(190px, 0.85fr)", + render: (grant) => ( +
+ {grant.rechargeType || "-"} + + {grant.rechargeCoinAmount ? `${formatNumber(grant.rechargeCoinAmount)} 金币` : "-"} + +
+ ), + }, + { + 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) => ( +
+ {grant.grantId} + {grant.transactionId || grant.eventId || "-"} +
+ ), + }, + { + 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 ( + + + + + 0 + ? { + page: page.page, + pageSize: page.grants.pageSize || 50, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(grant) => grant.grantId} + /> + + + + + ); +} + +function GrantUser({ grant }) { + const user = grant.user || {}; + return ( +
+ +
+ {user.username || `用户 ${grant.userId}`} + + {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`} + +
+
+ ); +} + +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)); +} diff --git a/src/features/cumulative-recharge-reward/permissions.js b/src/features/cumulative-recharge-reward/permissions.js new file mode 100644 index 0000000..e8df7cf --- /dev/null +++ b/src/features/cumulative-recharge-reward/permissions.js @@ -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), + }; +} diff --git a/src/features/cumulative-recharge-reward/routes.js b/src/features/cumulative-recharge-reward/routes.js new file mode 100644 index 0000000..a307fc0 --- /dev/null +++ b/src/features/cumulative-recharge-reward/routes.js @@ -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, + }, +]; diff --git a/src/features/dashboard/components/DashboardFrequentMenus.jsx b/src/features/dashboard/components/DashboardFrequentMenus.jsx new file mode 100644 index 0000000..ace3265 --- /dev/null +++ b/src/features/dashboard/components/DashboardFrequentMenus.jsx @@ -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 ( +
+
+
+

常用页面

+

本地缓存的二级菜单访问次数

+
+
+ {frequentMenus.length ? ( +
+ {frequentMenus.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+ ) : ( +
暂无本地访问记录
+ )} +
+ ); +} diff --git a/src/features/dashboard/dashboard.css b/src/features/dashboard/dashboard.css index fa10cb8..8de2adf 100644 --- a/src/features/dashboard/dashboard.css +++ b/src/features/dashboard/dashboard.css @@ -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); diff --git a/src/features/dashboard/pages/OverviewPage.jsx b/src/features/dashboard/pages/OverviewPage.jsx index 17668f7..7a25e7e 100644 --- a/src/features/dashboard/pages/OverviewPage.jsx +++ b/src/features/dashboard/pages/OverviewPage.jsx @@ -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() { + diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 0effe24..48b6086 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -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; diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index db255b2..fa158fc 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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: "", diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 99403d8..019f333 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -49,6 +49,7 @@ const adapterTypeOptions = [ ["leadercc_v1", "灵仙 LeaderCC V1"], ["zeeone_v1", "ZeeOne V1"], ["baishun_v1", "百顺 BAISHUN V1"], + ["vivagames_v1", "VIVAGAMES V1"], ]; const baseColumns = [ diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index db4fc20..547585d 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -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"); diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js index 2163555..e2c9489 100644 --- a/src/features/host-org/hooks/useHostCoinSellersPage.js +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -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, diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index b944a20..9420e07 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -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; diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index 4b2d8cc..7d0b20f 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -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) => , 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: ( + + ), + }; + } 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="编辑联系方式" > - + {contact || "未填写"}; - } - return ( - + 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 ( + + ); +} + const coinLedgerActionSx = { borderColor: "rgba(37, 99, 235, 0.28)", backgroundColor: "rgba(37, 99, 235, 0.08)", diff --git a/src/features/host-org/pages/HostCoinSellersPage.test.jsx b/src/features/host-org/pages/HostCoinSellersPage.test.jsx index 05e4eb7..6cc99b8 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.test.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.test.jsx @@ -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(); + + 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(); + + 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(); + + 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", diff --git a/src/features/host-org/schema.test.ts b/src/features/host-org/schema.test.ts new file mode 100644 index 0000000..4ad2987 --- /dev/null +++ b/src/features/host-org/schema.test.ts @@ -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); + } +}); diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts index 76f9178..8f5e488 100644 --- a/src/features/host-org/schema.ts +++ b/src/features/host-org/schema.ts @@ -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), }); diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index 01eaaae..59393cf 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -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"); +}); diff --git a/src/features/operations/api.ts b/src/features/operations/api.ts index 1d4c619..7942d1b 100644 --- a/src/features/operations/api.ts +++ b/src/features/operations/api.ts @@ -42,6 +42,14 @@ export function listCoinSellerLedger(query: PageQuery = {}): Promise { + return apiRequest("/v1/admin/operations/coin-seller-ledger/export", { + method: "GET", + query, + raw: true, + }); +} + export function listCoinAdjustments(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.listCoinAdjustments; return apiRequest>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), { diff --git a/src/features/operations/components/CoinSellerLedgerTable.jsx b/src/features/operations/components/CoinSellerLedgerTable.jsx index d7c2eb4..ea610e7 100644 --- a/src/features/operations/components/CoinSellerLedgerTable.jsx +++ b/src/features/operations/components/CoinSellerLedgerTable.jsx @@ -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) => , width: "minmax(240px, 1.2fr)", }, + { + key: "operator", + label: "操作人", + render: (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 ( - <> +
} + actions={ + + } /> @@ -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 = "" } /> - +
+ ); +} + +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 ( + + {name} + {operatorId && operatorId !== name ? {operatorId} : null} + ); } diff --git a/src/features/operations/operations.module.css b/src/features/operations/operations.module.css index bf0f065..d9a64f3 100644 --- a/src/features/operations/operations.module.css +++ b/src/features/operations/operations.module.css @@ -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); } diff --git a/src/features/registration-reward/api.test.ts b/src/features/registration-reward/api.test.ts new file mode 100644 index 0000000..d1817ad --- /dev/null +++ b/src/features/registration-reward/api.test.ts @@ -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" }, + }); +}); diff --git a/src/features/registration-reward/api.ts b/src/features/registration-reward/api.ts index 766f7bb..3c3be2c 100644 --- a/src/features/registration-reward/api.ts +++ b/src/features/registration-reward/api.ts @@ -48,8 +48,13 @@ export interface RegistrationRewardClaimDto { updatedAtMs?: number; } +export interface RegistrationRewardClaimPageDto extends ApiPage { + todayClaimedCount: number; +} + type RawConfig = RegistrationRewardConfigDto & Record; type RawClaim = RegistrationRewardClaimDto & Record; +type RawClaimPage = ApiPage & Record; export function getRegistrationRewardConfig(): Promise { const endpoint = API_ENDPOINTS.getRegistrationRewardConfig; @@ -71,14 +76,15 @@ export function updateRegistrationRewardConfig( ).then(normalizeConfig); } -export function listRegistrationRewardClaims(query: PageQuery = {}): Promise> { +export function listRegistrationRewardClaims(query: PageQuery = {}): Promise { const endpoint = API_ENDPOINTS.listRegistrationRewardClaims; - return apiRequest>(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), { + return apiRequest(apiEndpointPath(API_OPERATIONS.listRegistrationRewardClaims), { method: endpoint.method, query, }).then((page) => ({ ...page, items: (page.items || []).map(normalizeClaim), + todayClaimedCount: numberValue(page.todayClaimedCount ?? page.today_claimed_count), })); } diff --git a/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx b/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx index 21c4a70..04f2d0a 100644 --- a/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx +++ b/src/features/registration-reward/components/RegistrationRewardConfigSummary.jsx @@ -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 (
@@ -21,10 +25,12 @@ export function RegistrationRewardConfigSummary({ + {formatNumber(todayClaimedCount)} 份 {config ? formatNumber(config.dailyLimit) : configLoading ? "加载中" : "-"} {config ? rewardLabel(config, resourceGroups) : configLoading ? "加载中" : "-"}
+ diff --git a/src/features/registration-reward/hooks/useRegistrationRewardPage.js b/src/features/registration-reward/hooks/useRegistrationRewardPage.js index 86736a7..7b8c53a 100644 --- a/src/features/registration-reward/hooks/useRegistrationRewardPage.js +++ b/src/features/registration-reward/hooks/useRegistrationRewardPage.js @@ -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, }; } diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.jsx index 1582983..a17d5d4 100644 --- a/src/features/registration-reward/pages/RegistrationRewardPage.jsx +++ b/src/features/registration-reward/pages/RegistrationRewardPage.jsx @@ -44,12 +44,7 @@ const columnsBase = [ key: "claimId", label: "领取记录", width: "minmax(240px, 1fr)", - render: (claim) => ( -
- {claim.claimId} - {claim.rewardDay || "-"} -
- ), + 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} /> @@ -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 (
- {user.username || `用户 ${claim.userId}`} - {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} + {displayUserId ? `${name} (${displayUserId})` : name} + {displayUserId ? null : ID {claim.userId}}
); @@ -157,7 +157,6 @@ function RewardSummary({ claim }) { return (
{formatNumber(claim.coinAmount)} 金币 - 注册奖励
); } diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx new file mode 100644 index 0000000..5cd46f1 --- /dev/null +++ b/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx @@ -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(); + + 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: "" }, + }; +} diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index d8cae4f..f506ecc 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -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 ( - - ); - } - return ( - 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)} /> ); } diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 2d17e5a..735b38a 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -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; diff --git a/src/features/roles/components/RoleFormDrawer.jsx b/src/features/roles/components/RoleFormDrawer.jsx index 1f97d23..91d920e 100644 --- a/src/features/roles/components/RoleFormDrawer.jsx +++ b/src/features/roles/components/RoleFormDrawer.jsx @@ -17,7 +17,7 @@ export function RoleFormDrawer({ }) { return ( -
+

{editingRole ? "编辑角色" : "新增角色"}

基础信息
@@ -28,9 +28,9 @@ export function RoleFormDrawer({
{abilities.canLoadPermissions ? ( -
+
权限范围
-
+
{permissionGroups.map((group) => ( ; +} + +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; +type RawTier = RoomTurnoverRewardTierDto & Record; +type RawSettlement = RoomTurnoverRewardSettlementDto & Record; + +export function getRoomTurnoverRewardConfig(): Promise { + const endpoint = API_ENDPOINTS.getRoomTurnoverRewardConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getRoomTurnoverRewardConfig), { + method: endpoint.method, + }).then(normalizeConfig); +} + +export function updateRoomTurnoverRewardConfig( + payload: RoomTurnoverRewardConfigPayload, +): Promise { + const endpoint = API_ENDPOINTS.updateRoomTurnoverRewardConfig; + return apiRequest( + apiEndpointPath(API_OPERATIONS.updateRoomTurnoverRewardConfig), + { + body: payload, + method: endpoint.method, + }, + ).then(normalizeConfig); +} + +export function listRoomTurnoverRewardSettlements( + query: RoomTurnoverRewardSettlementQuery = {}, +): Promise> { + const endpoint = API_ENDPOINTS.listRoomTurnoverRewardSettlements; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listRoomTurnoverRewardSettlements), { + method: endpoint.method, + query, + }).then((page) => ({ + ...page, + items: (page.items || []).map(normalizeSettlement), + })); +} + +export function retryRoomTurnoverRewardSettlement( + settlementId: string, +): Promise { + const endpoint = API_ENDPOINTS.retryRoomTurnoverRewardSettlement; + return apiRequest( + 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; +} diff --git a/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx new file mode 100644 index 0000000..620f175 --- /dev/null +++ b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx @@ -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 ( + + +

房间流水奖励配置

+
+
结算状态
+ setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> +
+
+
+ 奖励档位 + +
+
+ {(form.tiers || []).map((tier, index) => ( +
+
+ 档位 {index + 1} + removeTier(index)} + > + + +
+
+ updateTier(index, { tierCode: event.target.value })} + /> + updateTier(index, { tierName: event.target.value })} + /> + + updateTier(index, { thresholdCoinSpent: event.target.value }) + } + /> + + updateTier(index, { rewardCoinAmount: event.target.value }) + } + /> + updateTier(index, { status: event.target.value })} + > + {statusOptions.map(([value, label]) => ( + + {label} + + ))} + + updateTier(index, { sortOrder: event.target.value })} + /> +
+
+ ))} + {!form.tiers?.length ?
暂无档位
: null} +
+
+
+ + +
+ +
+ ); +} diff --git a/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigSummary.jsx b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigSummary.jsx new file mode 100644 index 0000000..52e488d --- /dev/null +++ b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigSummary.jsx @@ -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 ( +
+
+
+ 状态 + + {config?.enabled ? "开启" : "关闭"} + +
+
+ 启用档位 + {activeTiers.length} +
+
+ 最高奖励 + {formatNumber(maxReward(activeTiers))} 金币 +
+
+ 更新时间 + {config?.updatedAtMs ? config.updatedAtMs : "-"} +
+
+
+ + +
+
+ ); +} + +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)); +} diff --git a/src/features/room-turnover-reward/hooks/useRoomTurnoverRewardPage.js b/src/features/room-turnover-reward/hooks/useRoomTurnoverRewardPage.js new file mode 100644 index 0000000..4f826e0 --- /dev/null +++ b/src/features/room-turnover-reward/hooks/useRoomTurnoverRewardPage.js @@ -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, + }; +} diff --git a/src/features/room-turnover-reward/pages/RoomTurnoverRewardPage.jsx b/src/features/room-turnover-reward/pages/RoomTurnoverRewardPage.jsx new file mode 100644 index 0000000..ff3f30a --- /dev/null +++ b/src/features/room-turnover-reward/pages/RoomTurnoverRewardPage.jsx @@ -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) => ( +
+ {item.roomId || "-"} + 房主 {item.ownerUserId || "-"} +
+ ), + }, + { + key: "period", + label: "UTC 周期", + width: "minmax(260px, 1.1fr)", + render: (item) => ( +
+ {formatMillis(item.periodStartMs)} + 至 {formatMillis(item.periodEndMs)} +
+ ), + }, + { + key: "coinSpent", + label: "房间流水", + width: "minmax(160px, 0.7fr)", + render: (item) => `${formatNumber(item.coinSpent)} 金币`, + }, + { + key: "tier", + label: "命中档位", + width: "minmax(180px, 0.75fr)", + render: (item) => ( +
+ {item.tierCode || `档位 #${item.tierId || "-"}`} + 阈值 {formatNumber(item.thresholdCoinSpent)} +
+ ), + }, + { + 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) => ( +
+ {item.settlementId} + {item.walletTransactionId || item.walletCommandId || "-"} +
+ ), + }, + { + key: "actions", + label: "操作", + width: "112px", + fixed: "right", + resizable: false, + render: (item, context) => + item.status === "failed" && context.abilities.canRetry ? ( + context.retrySettlement(item)} + > + + + ) : ( + "-" + ), + }, +]; + +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 ( + + + + + 0 + ? { + page: page.page, + pageSize: page.settlements.pageSize || 50, + total, + onPageChange: page.setPage, + } + : undefined + } + rowKey={(item) => item.settlementId} + /> + + + + + ); +} + +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 {label}; +} + +function formatNumber(value) { + return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); +} diff --git a/src/features/room-turnover-reward/permissions.js b/src/features/room-turnover-reward/permissions.js new file mode 100644 index 0000000..a73933d --- /dev/null +++ b/src/features/room-turnover-reward/permissions.js @@ -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), + }; +} diff --git a/src/features/room-turnover-reward/room-turnover-reward.module.css b/src/features/room-turnover-reward/room-turnover-reward.module.css new file mode 100644 index 0000000..41e06d1 --- /dev/null +++ b/src/features/room-turnover-reward/room-turnover-reward.module.css @@ -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; + } +} diff --git a/src/features/room-turnover-reward/routes.js b/src/features/room-turnover-reward/routes.js new file mode 100644 index 0000000..7e6df2d --- /dev/null +++ b/src/features/room-turnover-reward/routes.js @@ -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, + }, +]; diff --git a/src/features/weekly-star/api.ts b/src/features/weekly-star/api.ts new file mode 100644 index 0000000..e1db4f9 --- /dev/null +++ b/src/features/weekly-star/api.ts @@ -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; + +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(endpoint.path, { + body: payload, + method: endpoint.method, + }).then(normalizeCycle); +} + +export function updateWeeklyStarCycle(cycleId: string, payload: WeeklyStarCyclePayload) { + const endpoint = API_ENDPOINTS.updateWeeklyStarCycle; + return apiRequest( + 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( + 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(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; +} diff --git a/src/features/weekly-star/pages/WeeklyStarPage.jsx b/src/features/weekly-star/pages/WeeklyStarPage.jsx new file mode 100644 index 0000000..6c54575 --- /dev/null +++ b/src/features/weekly-star/pages/WeeklyStarPage.jsx @@ -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 ( + + + + + + } + /> + + + + + + + 周期 + 区域 + UTC 时间 + 礼物 + 奖励资源组 + 状态 + 操作 + + + + {cycles.map((cycle) => ( + + + + {cycle.title || "-"} + + {cycle.cycleId} + + + + {cycle.regionId === 0 ? "默认区域 · 0" : cycle.regionId} + + + {formatUTC(cycle.startMs)} + + 至 {formatUTC(cycle.endMs)} + + + + + + {cycle.gifts.map((gift) => ( + + ))} + + + + + {cycle.rewards.map((reward) => ( + + ))} + + + + + + + + + + + + + + + ))} + {!cycles.length ? ( + + + + 暂无周星周期配置 + + + + ) : null} + +
+
+
+
+ + + + + {form.cycleId ? "编辑周星周期" : "新增周星周期"} + + + + setForm((current) => ({ ...current, title: event.target.value }))} + /> + + + setForm((current) => ({ ...current, regionId: event.target.value })) + } + /> + + 状态 + + + + + + setForm((current) => ({ ...current, startAt: event.target.value })) + } + /> + setForm((current) => ({ ...current, endAt: event.target.value }))} + /> + + + 活动礼物 + {[0, 1, 2].map((index) => ( + updateGift(index, value)} + /> + ))} + + Top1 / Top2 / Top3 资源组 + {[0, 1, 2].map((index) => ( + updateReward(index, value)} + /> + ))} + + + + + + + + + setLeaderboard({ cycle: null, loading: false, items: [], error: "" })} + renderRow={(item) => [ + item.rankNo, + item.userId, + formatNumber(item.score), + formatUTC(item.firstScoredAtMs), + formatUTC(item.lastScoredAtMs), + ]} + /> + setSettlements({ cycle: null, loading: false, items: [], error: "" })} + renderRow={(item) => [ + item.rankNo, + item.userId, + formatNumber(item.score), + groupLabel(item.resourceGroupId, resourceGroupOptions), + item.status || "-", + item.walletCommandId || "-", + ]} + /> + setToast("")}> + setToast("")}> + {toast} + + +
+ ); +} + +function OptionField({ label, onChange, options, value }) { + return ( + (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) => } + /> + ); +} + +function ResultDialog({ columns, emptyText, error, loading, onClose, open, renderRow, rows, title }) { + return ( + + + + {title} + + {loading ? ( + 加载中... + ) : error ? ( + {error} + ) : ( + + + + + {columns.map((column) => ( + {column} + ))} + + + + {rows.map((row, rowIndex) => ( + + {renderRow(row).map((cell, cellIndex) => ( + {cell} + ))} + + ))} + {!rows.length ? ( + + + + {emptyText} + + + + ) : null} + +
+
+ )} +
+
+ ); +} + +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)); +} diff --git a/src/features/weekly-star/routes.js b/src/features/weekly-star/routes.js new file mode 100644 index 0000000..ecac045 --- /dev/null +++ b/src/features/weekly-star/routes.js @@ -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, + }, +]; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index f30e61e..da98de0 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 26f21ee..da780c8 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 2459a51..9b6f7e8 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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; + 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; diff --git a/src/shared/hooks/usePaginatedQuery.js b/src/shared/hooks/usePaginatedQuery.js index f0df77d..bc78880 100644 --- a/src/shared/hooks/usePaginatedQuery.js +++ b/src/shared/hooks/usePaginatedQuery.js @@ -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" +]; diff --git a/src/shared/hooks/usePaginatedQuery.test.js b/src/shared/hooks/usePaginatedQuery.test.js new file mode 100644 index 0000000..a006548 --- /dev/null +++ b/src/shared/hooks/usePaginatedQuery.test.js @@ -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"]); +}); diff --git a/src/shared/ui/InlineEditableCell.jsx b/src/shared/ui/InlineEditableCell.jsx new file mode 100644 index 0000000..5ee320a --- /dev/null +++ b/src/shared/ui/InlineEditableCell.jsx @@ -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 ( + + + + ); + } + + return ( + + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }} + /> + + ); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 7c236d8..3e61373 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; } diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 63be812..1337214 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -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, diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 47288f0..a750367 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -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;