diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index a0072da..ef64dcb 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1271,6 +1271,16 @@ }, "x-permission": "gift:view", "x-permissions": ["gift:view"] + }, + "put": { + "operationId": "updateGiftTypes", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "gift:update", + "x-permissions": ["gift:update"] } }, "/admin/gift-types/{type_code}": { diff --git a/src/features/achievements/hooks/useAchievementsPage.js b/src/features/achievements/hooks/useAchievementsPage.js index 37288de..35c378c 100644 --- a/src/features/achievements/hooks/useAchievementsPage.js +++ b/src/features/achievements/hooks/useAchievementsPage.js @@ -11,7 +11,7 @@ import { import { useAchievementAbilities } from "@/features/achievements/permissions.js"; import { achievementFormSchema } from "@/features/achievements/schema"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" }; const groupPageQuery = { page: 1, page_size: 500, status: "active" }; diff --git a/src/features/achievements/pages/AchievementConfigPage.jsx b/src/features/achievements/pages/AchievementConfigPage.jsx index 0b5b5cb..fc0175a 100644 --- a/src/features/achievements/pages/AchievementConfigPage.jsx +++ b/src/features/achievements/pages/AchievementConfigPage.jsx @@ -195,7 +195,7 @@ export function AchievementConfigPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/app-config/pages/AppVersionManagementPage.jsx b/src/features/app-config/pages/AppVersionManagementPage.jsx index d614665..57d109f 100644 --- a/src/features/app-config/pages/AppVersionManagementPage.jsx +++ b/src/features/app-config/pages/AppVersionManagementPage.jsx @@ -51,10 +51,17 @@ export function AppVersionManagementPage() { /> - item.id} /> -
- {items.length} 条 -
+ item.id} + />
diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index 0757bf1..661bbf5 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -8,7 +8,7 @@ import { useAppUserAbilities } from "@/features/app-users/permissions.js"; import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema"; import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyEditForm = () => ({ avatar: "", gender: "", username: "" }); const emptyCountryForm = () => ({ country: "" }); diff --git a/src/features/app-users/pages/AppUserBanListPage.jsx b/src/features/app-users/pages/AppUserBanListPage.jsx index 9418512..7751067 100644 --- a/src/features/app-users/pages/AppUserBanListPage.jsx +++ b/src/features/app-users/pages/AppUserBanListPage.jsx @@ -9,7 +9,7 @@ import { listAppUserBans } from "@/features/app-users/api"; import { appUserStatusLabels } from "@/features/app-users/constants.js"; import styles from "@/features/app-users/app-users.module.css"; -const pageSize = 20; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function AppUserBanListPage() { diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index 21c19be..497dccc 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -99,7 +99,7 @@ export function AppUserListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/app-users/pages/AppUserLoginLogsPage.jsx b/src/features/app-users/pages/AppUserLoginLogsPage.jsx index 122395d..9aa96a7 100644 --- a/src/features/app-users/pages/AppUserLoginLogsPage.jsx +++ b/src/features/app-users/pages/AppUserLoginLogsPage.jsx @@ -1,10 +1,11 @@ import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toPageQuery } from "@/shared/api/query"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; @@ -12,7 +13,7 @@ import { formatMillis } from "@/shared/utils/time.js"; import { listAppUserLoginLogs } from "@/features/app-users/api"; import styles from "@/features/app-users/app-users.module.css"; -const pageSize = 20; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const resultOptions = [ { label: "成功", value: "success" }, @@ -26,6 +27,7 @@ export function AppUserLoginLogsPage() { const [result, setResult] = useState(""); const [regionId, setRegionId] = useState(""); const [page, setPage] = useState(1); + const [mergedData, setMergedData] = useState(emptyData); const { loadingRegions, regionOptions } = useRegionOptions(); const userFilter = searchParams.get("user_id") || ""; const regionFilterOptions = useMemo(() => [{ label: "GLOBAL · 0", value: "0" }, ...regionOptions], [regionOptions]); @@ -43,13 +45,41 @@ export function AppUserLoginLogsPage() { [page, query, regionId, result, userFilter], ); const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]); - const { data, error, loading, reload } = useAdminQuery(loadLogs, { + const { data, error, loading: queryLoading, reload } = useAdminQuery(loadLogs, { errorMessage: "加载用户登录日志失败", initialData: emptyData, keepPreviousData: true, queryKey: ["app-users", "login-logs", requestQuery], }); + useEffect(() => { + setMergedData(emptyData); + }, [query, regionId, result, userFilter]); + + useEffect(() => { + const nextData = data || emptyData; + const nextItems = Array.isArray(nextData.items) ? nextData.items : []; + const nextPage = Number(nextData.page || page || 1); + const nextPageSize = Number(nextData.pageSize || pageSize); + const nextTotal = Number(nextData.total || 0); + + setMergedData((current) => { + if (nextPage <= 1) { + return { ...nextData, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + + return { + ...nextData, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextPageSize, + total: nextTotal, + }; + }); + }, [data, page]); + + const loading = queryLoading && (mergedData.items || []).length === 0; + const changeQuery = useCallback((value) => { setQuery(value); setPage(1); @@ -101,9 +131,9 @@ export function AppUserLoginLogsPage() { if (!userFilter) { return ""; } - const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter)); + const matchedLog = (mergedData.items || []).find((log) => String(log.userId || "") === String(userFilter)); return matchedLog?.displayUserId || matchedLog?.username || userFilter; - }, [data.items, userFilter]); + }, [mergedData.items, userFilter]); const columns = useMemo( () => [ @@ -222,16 +252,16 @@ export function AppUserLoginLogsPage() { loginLogRowProps(log, showUserHistory)} rowKey={(log) => log.id} pagination={ - data.total > 0 + mergedData.total > 0 ? { page, - pageSize: data.pageSize || pageSize, - total: data.total, + pageSize: mergedData.pageSize || pageSize, + total: mergedData.total, onPageChange: setPage, } : undefined diff --git a/src/features/daily-tasks/hooks/useDailyTasksPage.js b/src/features/daily-tasks/hooks/useDailyTasksPage.js index 705bc95..f1e4332 100644 --- a/src/features/daily-tasks/hooks/useDailyTasksPage.js +++ b/src/features/daily-tasks/hooks/useDailyTasksPage.js @@ -11,7 +11,7 @@ import { import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js"; import { dailyTaskFormSchema } from "@/features/daily-tasks/schema"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = (task = {}) => ({ category: task.category || "", diff --git a/src/features/daily-tasks/pages/DailyTaskListPage.jsx b/src/features/daily-tasks/pages/DailyTaskListPage.jsx index 342cddb..6ca870e 100644 --- a/src/features/daily-tasks/pages/DailyTaskListPage.jsx +++ b/src/features/daily-tasks/pages/DailyTaskListPage.jsx @@ -187,7 +187,7 @@ export function DailyTaskListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js b/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js index ba67d80..0f3111f 100644 --- a/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js +++ b/src/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js @@ -9,7 +9,7 @@ import { useFirstRechargeRewardAbilities } from "@/features/first-recharge-rewar import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -const pageSize = 10; +const pageSize = 50; const emptyClaims = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = { diff --git a/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx index 60b81d6..76923ef 100644 --- a/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx +++ b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx @@ -123,7 +123,7 @@ export function FirstRechargeRewardPage() { total > 0 ? { page: page.page, - pageSize: page.claims.pageSize || 10, + pageSize: page.claims.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 4ad095d..74a2399 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -180,10 +180,8 @@ export function GameListPage() { pagination={{ hasNextPage: Boolean(page.data.nextCursor), itemCount: items.length, - onPageSizeChange: page.changePageSize, page: page.page, pageSize: page.pageSize, - pageSizeOptions: [20, 50, 100], total: page.data.total, totalPages: page.data.totalPages, }} diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js index 3bfc9c8..587ed74 100644 --- a/src/features/host-org/hooks/useHostAgenciesPage.js +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -23,7 +23,7 @@ const emptyJoinEnabledForm = () => ({ joinEnabled: true, reason: "", }); -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function useHostAgenciesPage() { diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js index 54fca9e..ac019c0 100644 --- a/src/features/host-org/hooks/useHostBdsPage.js +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -19,7 +19,7 @@ import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyLookup = () => ({ error: "", loading: false, user: null }); diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js index b32a06d..bdeccff 100644 --- a/src/features/host-org/hooks/useHostCoinSellersPage.js +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -11,7 +11,7 @@ import { createCoinSellerSchema, } from "@/features/host-org/schema"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", targetUserId: "" }); const emptyEditForm = () => ({ diff --git a/src/features/host-org/hooks/useHostHostsPage.js b/src/features/host-org/hooks/useHostHostsPage.js index 4637859..555442a 100644 --- a/src/features/host-org/hooks/useHostHostsPage.js +++ b/src/features/host-org/hooks/useHostHostsPage.js @@ -4,7 +4,7 @@ import { listHosts } from "@/features/host-org/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useHostAbilities } from "@/features/host-org/permissions.js"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function useHostHostsPage() { diff --git a/src/features/host-org/hooks/useHostManagersPage.js b/src/features/host-org/hooks/useHostManagersPage.js index 3c09c0f..a2ecd2d 100644 --- a/src/features/host-org/hooks/useHostManagersPage.js +++ b/src/features/host-org/hooks/useHostManagersPage.js @@ -5,7 +5,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { closeAgency, listAgencies } from "@/features/host-org/api"; import { useAgencyAbilities } from "@/features/host-org/permissions.js"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function useHostManagersPage() { diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index d8d28a8..63c7e6d 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -139,7 +139,7 @@ export function HostAgenciesPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx index d29e6f5..02e7866 100644 --- a/src/features/host-org/pages/HostBdLeadersPage.jsx +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -99,7 +99,7 @@ export function HostBdLeadersPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx index 2f25268..78baa64 100644 --- a/src/features/host-org/pages/HostBdsPage.jsx +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -121,7 +121,7 @@ export function HostBdsPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index 2c378cd..2cdbf04 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -143,7 +143,7 @@ export function HostCoinSellersPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostCountriesPage.jsx b/src/features/host-org/pages/HostCountriesPage.jsx index 2bab846..3bf772e 100644 --- a/src/features/host-org/pages/HostCountriesPage.jsx +++ b/src/features/host-org/pages/HostCountriesPage.jsx @@ -97,10 +97,12 @@ export function HostCountriesPage() { - item.countryId} /> -
- {total} 条 -
+ item.countryId} + pagination={{ page: 1, pageSize: Math.max(items.length, 1), total }} + />
diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx index 7e91db2..cd81fff 100644 --- a/src/features/host-org/pages/HostHostsPage.jsx +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -119,7 +119,7 @@ export function HostHostsPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostManagersPage.jsx b/src/features/host-org/pages/HostManagersPage.jsx index 69a3cc2..d7cc8da 100644 --- a/src/features/host-org/pages/HostManagersPage.jsx +++ b/src/features/host-org/pages/HostManagersPage.jsx @@ -111,7 +111,7 @@ export function HostManagersPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/host-org/pages/HostRegionsPage.jsx b/src/features/host-org/pages/HostRegionsPage.jsx index 51751e3..d977193 100644 --- a/src/features/host-org/pages/HostRegionsPage.jsx +++ b/src/features/host-org/pages/HostRegionsPage.jsx @@ -96,10 +96,12 @@ export function HostRegionsPage() {
- item.regionId} /> -
- {total} 条 -
+ item.regionId} + pagination={{ page: 1, pageSize: Math.max(items.length, 1), total }} + />
diff --git a/src/features/logs/hooks/useLogsPage.js b/src/features/logs/hooks/useLogsPage.js index e423ca1..1a49f04 100644 --- a/src/features/logs/hooks/useLogsPage.js +++ b/src/features/logs/hooks/useLogsPage.js @@ -1,16 +1,18 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { downloadCsv } from "@/shared/api/download"; import { toPageQuery } from "@/shared/api/query"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; import { exportLoginLogs, exportOperationLogs, listLoginLogs, listOperationLogs } from "@/features/logs/api"; import { useLogAbilities } from "@/features/logs/permissions.js"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], total: 0, page: 1, pageSize }; export function useLogsPage(type) { const [query, setQuery] = useState(""); const [page, setPage] = useState(1); + const [mergedData, setMergedData] = useState(emptyData); const abilities = useLogAbilities(); const isLogin = type === "login"; @@ -20,13 +22,41 @@ export function useLogsPage(type) { return loader(requestQuery); }, [isLogin, requestQuery]); - const { data, error, loading, reload } = useAdminQuery(queryFn, { + const { data, error, loading: queryLoading, reload } = useAdminQuery(queryFn, { errorMessage: "加载日志失败", initialData: emptyData, keepPreviousData: true, queryKey: ["logs", type, requestQuery], }); + useEffect(() => { + setMergedData(emptyData); + }, [query, type]); + + useEffect(() => { + const nextData = data || emptyData; + const nextItems = Array.isArray(nextData.items) ? nextData.items : []; + const nextPage = Number(nextData.page || page || 1); + const nextPageSize = Number(nextData.pageSize || pageSize); + const nextTotal = Number(nextData.total || 0); + + setMergedData((current) => { + if (nextPage <= 1) { + return { ...nextData, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + + return { + ...nextData, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextPageSize, + total: nextTotal, + }; + }); + }, [data, page]); + + const loading = queryLoading && (mergedData.items || []).length === 0; + const changeQuery = (value) => { setQuery(value); setPage(1); @@ -46,7 +76,7 @@ export function useLogsPage(type) { return { abilities, changeQuery, - data, + data: mergedData, downloadLogs, error, isLogin, diff --git a/src/features/logs/pages/LogsPage.jsx b/src/features/logs/pages/LogsPage.jsx index 65b4cbf..daaccd4 100644 --- a/src/features/logs/pages/LogsPage.jsx +++ b/src/features/logs/pages/LogsPage.jsx @@ -16,7 +16,7 @@ export function LogsPage({ type }) { isLogin={page.isLogin} pagination={{ page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total: page.data.total || 0, onPageChange: page.setPage, }} diff --git a/src/features/operations/pages/CoinAdjustmentPage.jsx b/src/features/operations/pages/CoinAdjustmentPage.jsx index 3edae6a..b944b07 100644 --- a/src/features/operations/pages/CoinAdjustmentPage.jsx +++ b/src/features/operations/pages/CoinAdjustmentPage.jsx @@ -29,7 +29,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; -const pageSize = 20; +const pageSize = 50; const adjustmentReasons = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"]; diff --git a/src/features/operations/pages/CoinLedgerPage.jsx b/src/features/operations/pages/CoinLedgerPage.jsx index ed31a9d..3b3ee4e 100644 --- a/src/features/operations/pages/CoinLedgerPage.jsx +++ b/src/features/operations/pages/CoinLedgerPage.jsx @@ -16,7 +16,7 @@ import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; import styles from "@/features/operations/operations.module.css"; -const pageSize = 20; +const pageSize = 50; const columns = [ { diff --git a/src/features/payment/hooks/useRechargeProductsPage.js b/src/features/payment/hooks/useRechargeProductsPage.js index 833c92f..5bc7a93 100644 --- a/src/features/payment/hooks/useRechargeProductsPage.js +++ b/src/features/payment/hooks/useRechargeProductsPage.js @@ -13,7 +13,7 @@ import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -const pageSize = 20; +const pageSize = 50; const emptyForm = () => ({ amountUsdt: "", diff --git a/src/features/payment/pages/PaymentBillListPage.jsx b/src/features/payment/pages/PaymentBillListPage.jsx index 7ba94f8..4b6c12e 100644 --- a/src/features/payment/pages/PaymentBillListPage.jsx +++ b/src/features/payment/pages/PaymentBillListPage.jsx @@ -10,7 +10,7 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { formatMillis } from "@/shared/utils/time.js"; -const pageSize = 20; +const pageSize = 50; const statusOptions = [ ["", "全部状态"], ["succeeded", "成功"], diff --git a/src/features/red-packets/hooks/useRedPacketPage.js b/src/features/red-packets/hooks/useRedPacketPage.js index 013b9d8..12c6dff 100644 --- a/src/features/red-packets/hooks/useRedPacketPage.js +++ b/src/features/red-packets/hooks/useRedPacketPage.js @@ -9,7 +9,7 @@ import { useRedPacketAbilities } from "@/features/red-packets/permissions.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -const pageSize = 10; +const pageSize = 50; const emptyPackets = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = { diff --git a/src/features/red-packets/pages/RedPacketPage.jsx b/src/features/red-packets/pages/RedPacketPage.jsx index ff6bfd5..734bfdf 100644 --- a/src/features/red-packets/pages/RedPacketPage.jsx +++ b/src/features/red-packets/pages/RedPacketPage.jsx @@ -179,7 +179,7 @@ export function RedPacketPage() { total > 0 ? { page: page.page, - pageSize: page.packets.pageSize || 10, + pageSize: page.packets.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/registration-reward/hooks/useRegistrationRewardPage.js b/src/features/registration-reward/hooks/useRegistrationRewardPage.js index af31927..86736a7 100644 --- a/src/features/registration-reward/hooks/useRegistrationRewardPage.js +++ b/src/features/registration-reward/hooks/useRegistrationRewardPage.js @@ -9,7 +9,7 @@ import { } from "@/features/registration-reward/api"; import { useRegistrationRewardAbilities } from "@/features/registration-reward/permissions.js"; -const pageSize = 10; +const pageSize = 50; const emptyClaims = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = { diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.jsx index 104c2c6..1582983 100644 --- a/src/features/registration-reward/pages/RegistrationRewardPage.jsx +++ b/src/features/registration-reward/pages/RegistrationRewardPage.jsx @@ -107,7 +107,7 @@ export function RegistrationRewardPage() { total > 0 ? { page: page.page, - pageSize: page.claims.pageSize || 10, + pageSize: page.claims.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts index 8f2cef6..34804b2 100644 --- a/src/features/resources/api.test.ts +++ b/src/features/resources/api.test.ts @@ -20,7 +20,7 @@ import { listGifts, listResources, updateGift, - updateGiftType, + updateGiftTypes, updateResource, updateResourceGroup, } from "./api"; @@ -266,18 +266,30 @@ test("emoji pack APIs use generated admin paths", async () => { }); }); -test("gift type APIs send tab key, display name and tab name fields", async () => { +test("gift type APIs save all tabs with one request", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response(JSON.stringify({ code: 0, data: [] }))), ); await listGiftTypes(); - await updateGiftType("normal", { - displayName: "普通礼物", - sortOrder: 10, - status: "active", - tabName: "Gift", + await updateGiftTypes({ + items: [ + { + displayName: "普通礼物", + sortOrder: 10, + status: "active", + tabKey: "normal", + tabName: "Gift", + }, + { + displayName: "幸运礼物", + sortOrder: 30, + status: "active", + tabKey: "lucky", + tabName: "Lucky", + }, + ], }); const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0]; @@ -285,10 +297,22 @@ test("gift type APIs send tab key, display name and tab name fields", async () = expect(String(listUrl)).toContain("/api/v1/admin/gift-types"); expect(listInit?.method).toBe("GET"); - expect(String(updateUrl)).toContain("/api/v1/admin/gift-types/normal"); + expect(String(updateUrl)).toContain("/api/v1/admin/gift-types"); + expect(String(updateUrl)).not.toContain("/api/v1/admin/gift-types/normal"); expect(updateInit?.method).toBe("PUT"); expect(JSON.parse(String(updateInit?.body))).toMatchObject({ - displayName: "普通礼物", - tabName: "Gift", + items: [ + { + displayName: "普通礼物", + tabKey: "normal", + tabName: "Gift", + }, + { + displayName: "幸运礼物", + tabKey: "lucky", + tabName: "Lucky", + }, + ], }); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); }); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index f789f7b..ffab74a 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -156,6 +156,10 @@ export interface GiftTypePayload { sortOrder: number; } +export interface GiftTypesPayload { + items: Array; +} + export interface ResourceGrantItemDto { createdAtMs?: number; durationMs?: number; @@ -345,6 +349,14 @@ export function updateGiftType(tabKey: string, payload: GiftTypePayload): Promis ); } +export function updateGiftTypes(payload: GiftTypesPayload): Promise { + const endpoint = API_ENDPOINTS.updateGiftTypes; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateGiftTypes), { + body: payload, + method: endpoint.method, + }); +} + export function createGift(payload: GiftPayload): Promise { const endpoint = API_ENDPOINTS.createGift; return apiRequest(apiEndpointPath(API_OPERATIONS.createGift), { diff --git a/src/features/resources/components/ResourceSelectDrawer.jsx b/src/features/resources/components/ResourceSelectDrawer.jsx index 61afbf1..1e31b59 100644 --- a/src/features/resources/components/ResourceSelectDrawer.jsx +++ b/src/features/resources/components/ResourceSelectDrawer.jsx @@ -8,6 +8,9 @@ import { resourcePriceTypeLabels, resourceTypeLabels } from "@/features/resource import { SideDrawer } from "@/shared/ui/SideDrawer.jsx"; import styles from "@/features/resources/components/ResourceSelectDrawer.module.css"; +const drawerZIndex = 1600; +export const categoryMenuZIndex = drawerZIndex + 1; + export function ResourceSelectField({ disabled = false, drawerTitle = "选择资源", @@ -93,7 +96,7 @@ export function ResourceSelectField({ setResourceType(event.target.value)} > diff --git a/src/features/resources/components/ResourceSelectDrawer.test.jsx b/src/features/resources/components/ResourceSelectDrawer.test.jsx index 5b3c775..ea2d70d 100644 --- a/src/features/resources/components/ResourceSelectDrawer.test.jsx +++ b/src/features/resources/components/ResourceSelectDrawer.test.jsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; -import { ResourceSelectField } from "./ResourceSelectDrawer.jsx"; +import { categoryMenuZIndex, ResourceSelectField } from "./ResourceSelectDrawer.jsx"; vi.mock("@/shared/ui/SideDrawer.jsx", () => ({ SideDrawer({ children, drawerProps, onClose, open, title }) { @@ -47,6 +47,7 @@ test("resource select drawer stays above dialogs and filters by category", () => expect(screen.getByText("VIP Vehicle")).toBeInTheDocument(); fireEvent.mouseDown(screen.getByRole("combobox", { name: "分类" })); + expect(screen.getByTestId("resource-category-menu-root")).toHaveStyle(`z-index: ${categoryMenuZIndex}`); fireEvent.click(screen.getByRole("option", { name: "座驾" })); expect(screen.queryByText("Rose Gift")).not.toBeInTheDocument(); diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 0aeb22d..c04ed02 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -24,7 +24,7 @@ import { listResourceGroups, listResources, updateGift, - updateGiftType, + updateGiftTypes, updateResource, updateResourceGroup, } from "@/features/resources/api"; @@ -39,12 +39,15 @@ import { resourceGroupCreateFormSchema, } from "@/features/resources/schema.js"; -const pageSize = 10; +const pageSize = 50; const dayMillis = 24 * 60 * 60 * 1000; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyResourceForm = (resource = {}) => ({ animationUrl: resource.animationUrl || resource.assetUrl || "", - badgeForm: resource.resourceType === "badge" ? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile" : "tile", + badgeForm: + resource.resourceType === "badge" + ? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile" + : "tile", coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "", enabled: resource.status ? resource.status === "active" : true, name: resource.name || "", @@ -515,24 +518,6 @@ export function useGiftListPage() { queryKey: ["gifts", filters, page], }); - const loadGiftTypes = () => { - setGiftTypesLoading(true); - return listGiftTypes() - .then((items = []) => { - const nextItems = mergeGiftTypeOptions(items); - setGiftTypes(nextItems); - setGiftTypeForm({ items: nextItems.map(giftTypeToForm) }); - return nextItems; - }) - .catch((err) => { - showToast(err.message || "加载礼物类型失败", "error"); - return giftTypes; - }) - .finally(() => { - setGiftTypesLoading(false); - }); - }; - useEffect(() => { let ignore = false; setGiftTypesLoading(true); @@ -646,10 +631,17 @@ export function useGiftListPage() { setLoadingAction("gift-types"); try { const parsed = parseForm(giftTypeConfigFormSchema, giftTypeForm); - await Promise.all(parsed.items.map((item) => updateGiftType(item.tabKey, buildGiftTypePayload(item)))); + const savedItems = await updateGiftTypes({ + items: parsed.items.map((item) => ({ + tabKey: item.tabKey, + ...buildGiftTypePayload(item), + })), + }); + const nextItems = mergeGiftTypeOptions(savedItems); + setGiftTypes(nextItems); + setGiftTypeForm({ items: nextItems.map(giftTypeToForm) }); showToast("礼物类型已保存", "success"); setGiftTypeDialogOpen(false); - await loadGiftTypes(); } catch (err) { showToast(err.message || "保存礼物类型失败", "error"); } finally { @@ -1041,7 +1033,9 @@ function badgeFormFromMetadata(metadataJson) { } try { const metadata = JSON.parse(metadataJson); - const badgeForm = String(metadata?.badge_form || "").trim().toLowerCase(); + const badgeForm = String(metadata?.badge_form || "") + .trim() + .toLowerCase(); if (badgeForm === "strip" || badgeForm === "tile") { return badgeForm; } diff --git a/src/features/resources/pages/EmojiPackListPage.jsx b/src/features/resources/pages/EmojiPackListPage.jsx index b18f2d0..8f506a8 100644 --- a/src/features/resources/pages/EmojiPackListPage.jsx +++ b/src/features/resources/pages/EmojiPackListPage.jsx @@ -114,7 +114,7 @@ export function EmojiPackListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index e2955bb..0541c11 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -195,7 +195,7 @@ export function GiftListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index 7648708..dc6ee4a 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -137,7 +137,7 @@ export function ResourceGrantListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx index 45b84c3..d884b49 100644 --- a/src/features/resources/pages/ResourceGroupListPage.jsx +++ b/src/features/resources/pages/ResourceGroupListPage.jsx @@ -126,7 +126,7 @@ export function ResourceGroupListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index b2a19f0..980569f 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -144,7 +144,7 @@ export function ResourceListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/roles/components/RoleTable.jsx b/src/features/roles/components/RoleTable.jsx index 431f549..b6e70c5 100644 --- a/src/features/roles/components/RoleTable.jsx +++ b/src/features/roles/components/RoleTable.jsx @@ -4,7 +4,7 @@ import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLay import { DataTable } from "@/shared/ui/DataTable.jsx"; import { createTextColumnFilter } from "@/shared/ui/tableFilters.js"; -export function RoleTable({ abilities, onEdit, onQueryChange, onRemove, query, roles }) { +export function RoleTable({ abilities, onEdit, onQueryChange, onRemove, pagination, query, roles }) { const columns = [ { key: "name", @@ -46,6 +46,6 @@ export function RoleTable({ abilities, onEdit, onQueryChange, onRemove, query, r ]; return ( - role.id} /> + role.id} /> ); } diff --git a/src/features/roles/pages/RoleManagementPage.jsx b/src/features/roles/pages/RoleManagementPage.jsx index dbd0065..a1aba5b 100644 --- a/src/features/roles/pages/RoleManagementPage.jsx +++ b/src/features/roles/pages/RoleManagementPage.jsx @@ -34,11 +34,13 @@ export function RoleManagementPage() { onEdit={page.openEdit} onQueryChange={page.changeQuery} onRemove={page.remove} + pagination={{ + page: 1, + pageSize: Math.max(page.roles.length, 1), + total: page.roles.length, + }} roles={page.roles} /> -
- {page.roles.length} 条 -
diff --git a/src/features/rooms/hooks/useRoomPinsPage.js b/src/features/rooms/hooks/useRoomPinsPage.js index 437165c..6e72318 100644 --- a/src/features/rooms/hooks/useRoomPinsPage.js +++ b/src/features/rooms/hooks/useRoomPinsPage.js @@ -8,7 +8,7 @@ import { cancelRoomPin, createRoomPin, listRoomPins, listRooms } from "@/feature import { useRoomAbilities } from "@/features/rooms/permissions.js"; import { roomPinCreateSchema } from "@/features/rooms/schema"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = () => ({ durationDays: "30", roomId: "", weight: "0" }); diff --git a/src/features/rooms/hooks/useRoomsPage.js b/src/features/rooms/hooks/useRoomsPage.js index 986f361..8986f59 100644 --- a/src/features/rooms/hooks/useRoomsPage.js +++ b/src/features/rooms/hooks/useRoomsPage.js @@ -8,7 +8,7 @@ import { deleteRoom, listRooms, updateRoom } from "@/features/rooms/api"; import { useRoomAbilities } from "@/features/rooms/permissions.js"; import { roomStatusSchema, roomUpdateSchema } from "@/features/rooms/schema"; -const pageSize = 10; +const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = () => ({ coverUrl: "", diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx index 4db6704..1597fe5 100644 --- a/src/features/rooms/pages/RoomListPage.jsx +++ b/src/features/rooms/pages/RoomListPage.jsx @@ -134,7 +134,7 @@ export function RoomListPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 10, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/rooms/pages/RoomPinsPage.jsx b/src/features/rooms/pages/RoomPinsPage.jsx index 22afc22..1de861e 100644 --- a/src/features/rooms/pages/RoomPinsPage.jsx +++ b/src/features/rooms/pages/RoomPinsPage.jsx @@ -10,7 +10,6 @@ import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; -import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx"; @@ -135,15 +134,17 @@ export function RoomPinsPage() { items={items} minWidth="1240px" rowKey={(pin) => pin.id} + pagination={ + total > 0 + ? { + page: page.page, + pageSize: page.data.pageSize || 50, + total, + onPageChange: page.setPage, + } + : undefined + } /> - {total > 0 ? ( - - ) : null} diff --git a/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js b/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js index 33b1872..3f782c6 100644 --- a/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js +++ b/src/features/seven-day-checkin/hooks/useSevenDayCheckInPage.js @@ -11,7 +11,7 @@ import { import { useSevenDayCheckInAbilities } from "@/features/seven-day-checkin/permissions.js"; import { sevenDayCheckInConfigFormSchema } from "@/features/seven-day-checkin/schema"; -const pageSize = 10; +const pageSize = 50; const emptyClaims = { items: [], page: 1, pageSize, total: 0 }; const emptyForm = { diff --git a/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx index 04bcb7f..36deeab 100644 --- a/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx +++ b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx @@ -161,7 +161,7 @@ export function SevenDayCheckInPage() { total > 0 ? { page: page.page, - pageSize: page.claims.pageSize || 10, + pageSize: page.claims.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js b/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js index 8c19ef3..9f70495 100644 --- a/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js +++ b/src/features/user-leaderboard/hooks/useUserLeaderboardPage.js @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listUserLeaderboards } from "@/features/user-leaderboard/api"; import { useUserLeaderboardAbilities } from "@/features/user-leaderboard/permissions.js"; -const pageSize = 20; +const pageSize = 50; export function useUserLeaderboardPage() { const abilities = useUserLeaderboardAbilities(); @@ -25,7 +26,20 @@ export function useUserLeaderboardPage() { page_size: pageSize, period, }); - setData(nextData); + setData((current) => { + const nextPage = Number(nextData?.page || page || 1); + const nextItems = Array.isArray(nextData?.items) ? nextData.items : []; + if (nextPage <= 1) { + return { ...nextData, items: nextItems, page: nextPage, pageSize: nextData?.pageSize || pageSize }; + } + + return { + ...nextData, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextData?.pageSize || pageSize, + }; + }); } catch (err) { const message = err.message || "加载用户榜单失败"; setError(message); @@ -55,6 +69,10 @@ export function useUserLeaderboardPage() { setPage(1); }, []); + useEffect(() => { + setData(emptyData()); + }, [boardType, period]); + return { abilities, boardType, @@ -62,7 +80,7 @@ export function useUserLeaderboardPage() { changePeriod, data, error, - loading, + loading: loading && (data.items || []).length === 0, page, period, reload, diff --git a/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx index c650da3..6f4a10a 100644 --- a/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx +++ b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx @@ -118,7 +118,7 @@ export function UserLeaderboardPage() { total > 0 ? { page: page.page, - pageSize: page.data.pageSize || 20, + pageSize: page.data.pageSize || 50, total, onPageChange: page.setPage, } diff --git a/src/features/users/hooks/useUsersPage.js b/src/features/users/hooks/useUsersPage.js index abc3420..40fb5a7 100644 --- a/src/features/users/hooks/useUsersPage.js +++ b/src/features/users/hooks/useUsersPage.js @@ -1,9 +1,10 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { downloadCsv } from "@/shared/api/download"; import { toPageQuery } from "@/shared/api/query"; import { parseForm } from "@/shared/forms/validation"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js"; import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { listRoles } from "@/features/roles/api"; @@ -19,7 +20,7 @@ import { import { useUserAbilities } from "@/features/users/permissions.js"; import { userCreateFormSchema, userUpdateFormSchema } from "@/features/users/schema"; -const pageSize = 10; +const pageSize = 50; const emptyUsers = { items: [], total: 0, page: 1, pageSize }; const emptyData = { roles: [], users: emptyUsers }; @@ -43,6 +44,7 @@ export function useUsersPage() { const [editingUser, setEditingUser] = useState(null); const [profileUser, setProfileUser] = useState(null); const [form, setForm] = useState(emptyForm); + const [mergedUsers, setMergedUsers] = useState(emptyUsers); const abilities = useUserAbilities(); const confirm = useConfirm(); const { showToast } = useToast(); @@ -62,14 +64,41 @@ export function useUsersPage() { return { roles: roleData || [], users: userData || emptyUsers }; }, [abilities.canViewRoles, requestQuery]); - const { data, error, loading, reload } = useAdminQuery(queryFn, { + const { data, error, loading: queryLoading, reload } = useAdminQuery(queryFn, { errorMessage: "加载用户失败", initialData: emptyData, keepPreviousData: true, queryKey: ["users", requestQuery, abilities.canViewRoles], }); - const users = data?.users || emptyUsers; + useEffect(() => { + setMergedUsers(emptyUsers); + }, [query, roleFilter, statusFilter]); + + useEffect(() => { + const nextUsers = data?.users || emptyUsers; + const nextItems = Array.isArray(nextUsers.items) ? nextUsers.items : []; + const nextPage = Number(nextUsers.page || page || 1); + const nextPageSize = Number(nextUsers.pageSize || pageSize); + const nextTotal = Number(nextUsers.total || 0); + + setMergedUsers((current) => { + if (nextPage <= 1) { + return { ...nextUsers, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + + return { + ...nextUsers, + items: mergePaginatedItems(current.items || [], nextItems), + page: nextPage, + pageSize: nextPageSize, + total: nextTotal, + }; + }); + }, [data?.users, page]); + + const users = mergedUsers; + const loading = queryLoading && (users.items || []).length === 0; const roles = useMemo(() => data?.roles || [], [data?.roles]); const roleFilters = useMemo(() => [["", "全部角色"], ...roles.map((role) => [role.name, role.name])], [roles]); diff --git a/src/features/users/pages/UserManagementPage.jsx b/src/features/users/pages/UserManagementPage.jsx index a14b7c6..64e525a 100644 --- a/src/features/users/pages/UserManagementPage.jsx +++ b/src/features/users/pages/UserManagementPage.jsx @@ -81,7 +81,7 @@ export function UserManagementPage() { onStatusFilterChange={page.changeStatusFilter} pagination={{ page: page.page, - pageSize: page.users.pageSize || 10, + pageSize: page.users.pageSize || 50, total: page.users.total || 0, onPageChange: page.setPage, }} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index c0b9080..10fc5ae 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -169,6 +169,7 @@ export const API_OPERATIONS = { updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig", updateGift: "updateGift", updateGiftType: "updateGiftType", + updateGiftTypes: "updateGiftTypes", updateH5Link: "updateH5Link", updateH5Links: "updateH5Links", updateMenu: "updateMenu", @@ -1277,6 +1278,13 @@ export const API_ENDPOINTS: Record = { permission: "gift:update", permissions: ["gift:update"], }, + updateGiftTypes: { + method: "PUT", + operationId: API_OPERATIONS.updateGiftTypes, + path: "/v1/admin/gift-types", + permission: "gift:update", + permissions: ["gift:update"], + }, updateH5Link: { method: "PUT", operationId: API_OPERATIONS.updateH5Link, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 651204b..cdbfdee 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -860,7 +860,7 @@ export interface paths { cookie?: never; }; get: operations["listGiftTypes"]; - put?: never; + put: operations["updateGiftTypes"]; post?: never; delete?: never; options?: never; @@ -3578,6 +3578,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + updateGiftTypes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; updateGiftType: { parameters: { query?: never; diff --git a/src/shared/hooks/usePaginatedQuery.js b/src/shared/hooks/usePaginatedQuery.js index 046d433..f0df77d 100644 --- a/src/shared/hooks/usePaginatedQuery.js +++ b/src/shared/hooks/usePaginatedQuery.js @@ -1,20 +1,76 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toPageQuery } from "@/shared/api/query"; import { useAdminQuery } from "./useAdminQuery.js"; -const emptyPage = { items: [], total: 0, page: 1, pageSize: 10 }; +const defaultPageSize = 50; +const emptyPage = { items: [], total: 0, page: 1, pageSize: defaultPageSize }; -export function usePaginatedQuery({ errorMessage, fetcher, filters = {}, page, pageSize = 10, queryKey }) { +export function usePaginatedQuery({ errorMessage, fetcher, filters = {}, page, pageSize = defaultPageSize, queryKey }) { const requestQuery = useMemo( () => toPageQuery({ ...filters, page, pageSize }), [filters, page, pageSize] ); + const resetKey = useMemo(() => JSON.stringify({ filters, pageSize }), [filters, pageSize]); const queryFn = useCallback(() => fetcher(requestQuery), [fetcher, requestQuery]); + const [mergedData, setMergedData] = useState(emptyPage); - return useAdminQuery(queryFn, { + const query = useAdminQuery(queryFn, { errorMessage, initialData: emptyPage, keepPreviousData: true, queryKey: queryKey || ["paginated", requestQuery] }); + + useEffect(() => { + setMergedData({ ...emptyPage, pageSize }); + }, [pageSize, resetKey]); + + useEffect(() => { + const nextData = query.data || emptyPage; + const nextItems = Array.isArray(nextData.items) ? nextData.items : []; + const nextPage = Number(nextData.page || page || 1); + const nextPageSize = Number(nextData.pageSize || pageSize || defaultPageSize); + const nextTotal = Number(nextData.total || 0); + + setMergedData((current) => { + if (nextPage <= 1) { + return { ...nextData, items: nextItems, page: nextPage, pageSize: nextPageSize, total: nextTotal }; + } + + const mergedItems = mergePaginatedItems(current.items || [], nextItems); + return { + ...nextData, + items: mergedItems, + page: nextPage, + pageSize: nextPageSize, + total: nextTotal + }; + }); + }, [page, pageSize, query.data]); + + return { + ...query, + data: mergedData, + loading: query.loading && (mergedData.items || []).length === 0, + loadingMore: query.loading && (mergedData.items || []).length > 0 + }; +} + +export function mergePaginatedItems(currentItems, nextItems) { + const seen = new Set(); + return [...currentItems, ...nextItems].filter((item, index) => { + const key = itemKey(item, index); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +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}`; } diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index 7b0676a..84071a6 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -5,7 +5,7 @@ import MenuList from "@mui/material/MenuList"; import Popover from "@mui/material/Popover"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; @@ -38,19 +38,35 @@ export function DataTable({ const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" }); const [filterQuery, setFilterQuery] = useState(""); const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); + const [paginationLoadingMore, setPaginationLoadingMore] = useState(false); const [resizeSession, setResizeSession] = useState(null); const loadMoreRef = useRef(null); const scrollRef = useRef(null); const safeItems = Array.isArray(items) ? items : []; - const infiniteLoading = Boolean(infiniteScroll?.loading); - const infiniteHasMore = Boolean(infiniteScroll?.hasMore); + const paginationTotal = Number(pagination?.total || 0); + const paginationHasMore = + Boolean(pagination?.onPageChange) && + (Number.isFinite(paginationTotal) && paginationTotal > 0 + ? safeItems.length < paginationTotal + : Boolean(pagination?.hasNextPage)); + const infiniteLoading = Boolean(infiniteScroll?.loading || paginationLoadingMore); + const infiniteHasMore = Boolean(infiniteScroll?.hasMore || paginationHasMore); + const infiniteLoadLabel = infiniteScroll?.loadingLabel || "加载中..."; + const infiniteDoneLabel = infiniteScroll?.doneLabel || ""; + const loadMore = useCallback(() => { + if (infiniteScroll?.onLoadMore) { + infiniteScroll.onLoadMore(); + return; + } + pagination?.onPageChange?.(Number(pagination?.page || 1) + 1); + }, [infiniteScroll, pagination]); const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]); const preparedColumns = useMemo( () => - columns.map((column, index) => ({ + columns.map((column) => ({ ...column, - fixed: resolveColumnFixed(column, index, columns.length, fixedEdges && hasHorizontalOverflow), + fixed: resolveColumnFixed(column, fixedEdges && hasHorizontalOverflow), width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH })), [columnWidths, columns, fixedEdges, hasHorizontalOverflow] @@ -139,14 +155,15 @@ export function DataTable({ useEffect(() => { const root = scrollRef.current; const target = loadMoreRef.current; - if (!root || !target || !infiniteScroll?.onLoadMore || !infiniteHasMore || infiniteLoading) { + if (!root || !target || !loadMore || !infiniteHasMore || infiniteLoading) { return undefined; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { - infiniteScroll.onLoadMore(); + setPaginationLoadingMore(Boolean(paginationHasMore)); + loadMore(); } }, { root, rootMargin: "0px 0px 96px", threshold: 0.01 } @@ -156,7 +173,11 @@ export function DataTable({ return () => { observer.disconnect(); }; - }, [infiniteHasMore, infiniteLoading, infiniteScroll, safeItems.length]); + }, [infiniteHasMore, infiniteLoading, loadMore, paginationHasMore, safeItems.length]); + + useEffect(() => { + setPaginationLoadingMore(false); + }, [safeItems.length]); const startResize = (event, column) => { if (!resizableColumns || column.resizable === false) { @@ -288,7 +309,7 @@ export function DataTable({ ); })} - {infiniteScroll ? ( + {infiniteScroll || paginationHasMore || paginationLoadingMore ? (
- {infiniteLoading ? infiniteScroll.loadingLabel || "加载中..." : infiniteHasMore ? "" : infiniteScroll.doneLabel || ""} + {infiniteLoading ? infiniteLoadLabel : infiniteHasMore ? "" : infiniteDoneLabel}
) : null} @@ -304,7 +325,7 @@ export function DataTable({ ) : infiniteLoading ? (
- {infiniteScroll?.loadingLabel || "加载中..."} + {infiniteLoadLabel}
) : ( @@ -312,12 +333,17 @@ export function DataTable({
{emptyLabel}
)} - {safeItems.length === 0 && infiniteScroll && !infiniteLoading ? ( + {safeItems.length === 0 && (infiniteScroll || paginationHasMore) && !infiniteLoading ? (
) : null}
- {pagination ? : null} + {pagination ? ( + + ) : null} = resolvedTotalPages : !hasNextPage; - const showPageActions = typeof onPageChange === "function"; - const showPageSize = showPageActions && pageSizeOptions.length && onPageSizeChange; - const pageSummary = hasKnownTotal ? `共 ${resolvedTotalPages} 页 · 共 ${total} 条` : `已加载 ${Math.max(1, page)} 页 · ${end} 条`; + const loadedCount = Number(itemCount || 0); + const pageSummary = hasKnownTotal ? `已加载 ${loadedCount} 条 · 共 ${total} 条` : `已加载 ${loadedCount} 条`; return (
- {showPageSize ? ( - - 每页 - onPageSizeChange(Number(event.target.value))} - > - {pageSizeOptions.map((option) => ( - - {option} - - ))} - - - ) : null} - {showPageActions && hasKnownTotal ? `${start}-${end} / ${total}` : pageSummary} + {pageSummary}
- {showPageActions ? ( -
- onPageChange(page - 1)}> - - - {page} / {resolvedTotalPages} - onPageChange(page + 1)}> - - -
- ) : null}
); } diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index f386c8f..ac1adc5 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1008,33 +1008,6 @@ gap: var(--space-2); } -.pagination-bar__page-size-wrap { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -.pagination-bar__page-size { - width: 72px; -} - -.pagination-bar__page-size .MuiInputBase-root { - height: 30px; - border-radius: var(--radius-control); - font-size: 12px; -} - -.pagination-bar__page-size .MuiSelect-select.MuiInputBase-input { - padding-top: 4px; - padding-bottom: 4px; -} - -.pagination-bar__actions { - display: flex; - align-items: center; - gap: var(--space-1); -} - .data-state { display: grid; min-height: 180px; diff --git a/src/test/setup.js b/src/test/setup.js index f149f27..c226d1a 100644 --- a/src/test/setup.js +++ b/src/test/setup.js @@ -1 +1,7 @@ import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +});