优化表单

This commit is contained in:
zhx 2026-05-26 02:39:46 +08:00
parent f2c7ccf7f3
commit 03869b3a37
65 changed files with 431 additions and 236 deletions

View File

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

View File

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

View File

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

View File

@ -51,10 +51,17 @@ export function AppVersionManagementPage() {
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1080px" rowKey={(item) => item.id} />
<div className="pagination-bar">
<span>{items.length} </span>
</div>
<DataTable
columns={columns}
items={items}
minWidth="1080px"
pagination={{
page: 1,
pageSize: Math.max(items.length, 1),
total: page.data.total || items.length
}}
rowKey={(item) => item.id}
/>
</AdminListBody>
</DataState>

View File

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

View File

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

View File

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

View File

@ -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() {
<DataTable
columns={columns}
emptyLabel="暂无登录日志"
items={data.items || []}
items={mergedData.items || []}
minWidth="1380px"
getRowProps={(log) => 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -97,10 +97,12 @@ export function HostCountriesPage() {
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.countryId} />
<div className="pagination-bar">
<span>{total} </span>
</div>
<HostOrgTable
columns={columns}
items={items}
rowKey={(item) => item.countryId}
pagination={{ page: 1, pageSize: Math.max(items.length, 1), total }}
/>
</AdminListBody>
</DataState>

View File

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

View File

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

View File

@ -96,10 +96,12 @@ export function HostRegionsPage() {
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<HostOrgTable columns={columns} items={items} rowKey={(item) => item.regionId} />
<div className="pagination-bar">
<span>{total} </span>
</div>
<HostOrgTable
columns={columns}
items={items}
rowKey={(item) => item.regionId}
pagination={{ page: 1, pageSize: Math.max(items.length, 1), total }}
/>
</div>
</DataState>
</div>

View File

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

View File

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

View File

@ -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 = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"];

View File

@ -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 = [
{

View File

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

View File

@ -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", "成功"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -156,6 +156,10 @@ export interface GiftTypePayload {
sortOrder: number;
}
export interface GiftTypesPayload {
items: Array<GiftTypePayload & { tabKey: string }>;
}
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<GiftTypeDto[]> {
const endpoint = API_ENDPOINTS.updateGiftTypes;
return apiRequest<GiftTypeDto[], GiftTypesPayload>(apiEndpointPath(API_OPERATIONS.updateGiftTypes), {
body: payload,
method: endpoint.method,
});
}
export function createGift(payload: GiftPayload): Promise<GiftDto> {
const endpoint = API_ENDPOINTS.createGift;
return apiRequest<GiftDto, GiftPayload>(apiEndpointPath(API_OPERATIONS.createGift), {

View File

@ -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({
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
drawerProps={{ sx: { zIndex: drawerZIndex } }}
open={open}
title={drawerTitle}
width="wide"
@ -119,6 +122,18 @@ export function ResourceSelectField({
select
className={styles.typeFilter}
label="分类"
slotProps={{
select: {
MenuProps: {
slotProps: {
root: {
"data-testid": "resource-category-menu-root",
style: { zIndex: categoryMenuZIndex },
},
},
},
},
}}
value={resourceType}
onChange={(event) => setResourceType(event.target.value)}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<DataTable columns={columns} items={roles} minWidth="920px" rowKey={(role) => role.id} />
<DataTable columns={columns} items={roles} minWidth="920px" pagination={pagination} rowKey={(role) => role.id} />
);
}

View File

@ -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}
/>
<div className="pagination-bar">
<span>{page.roles.length} </span>
</div>
</AdminListBody>
</DataState>
</AdminListPage>

View File

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

View File

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

View File

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

View File

@ -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 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</div>
</DataState>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

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

View File

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

View File

@ -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({
</div>
);
})}
{infiniteScroll ? (
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
<div
className={["admin-row", "admin-row--load-more", !infiniteHasMore && !infiniteLoading ? "admin-row--load-more-done" : ""]
.filter(Boolean)
@ -296,7 +317,7 @@ export function DataTable({
ref={loadMoreRef}
>
<div className="admin-cell admin-cell--load-more">
{infiniteLoading ? infiniteScroll.loadingLabel || "加载中..." : infiniteHasMore ? "" : infiniteScroll.doneLabel || ""}
{infiniteLoading ? infiniteLoadLabel : infiniteHasMore ? "" : infiniteDoneLabel}
</div>
</div>
) : null}
@ -304,7 +325,7 @@ export function DataTable({
) : infiniteLoading ? (
<div className="admin-row admin-row--load-more" ref={loadMoreRef}>
<div className="admin-cell admin-cell--load-more">
{infiniteScroll?.loadingLabel || "加载中..."}
{infiniteLoadLabel}
</div>
</div>
) : (
@ -312,12 +333,17 @@ export function DataTable({
<div className="empty-state__title">{emptyLabel}</div>
</div>
)}
{safeItems.length === 0 && infiniteScroll && !infiniteLoading ? (
{safeItems.length === 0 && (infiniteScroll || paginationHasMore) && !infiniteLoading ? (
<div className="admin-row--load-more-sentinel" ref={loadMoreRef} />
) : null}
</div>
</div>
{pagination ? <PaginationBar {...pagination} /> : null}
{pagination ? (
<PaginationBar
{...pagination}
itemCount={safeItems.length}
/>
) : null}
<Popover
anchorEl={filterMenu.anchorEl}
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
@ -457,19 +483,10 @@ function cellClassName(column, extraClassName = "") {
.join(" ");
}
function resolveColumnFixed(column, index, total, fixedEdges) {
if (column.fixed === "left" || column.fixed === "right") {
function resolveColumnFixed(column, fixedEdges) {
if (fixedEdges && (column.fixed === "left" || column.fixed === "right")) {
return column.fixed;
}
if (!fixedEdges || total < 2) {
return "";
}
if (index === 0) {
return "left";
}
if (index === total - 1) {
return "right";
}
return "";
}

View File

@ -1,68 +1,16 @@
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { IconButton } from "./IconButton.jsx";
export function PaginationBar({
hasNextPage,
itemCount,
onPageChange,
onPageSizeChange,
page,
pageSize,
pageSizeOptions = [],
total,
totalPages
}) {
const hasKnownTotal = Number.isFinite(Number(total));
const resolvedTotalPages = Number.isFinite(Number(totalPages))
? Math.max(1, Number(totalPages))
: hasKnownTotal
? Math.max(1, Math.ceil(total / pageSize))
: Math.max(1, page + (hasNextPage ? 1 : 0));
const start = hasKnownTotal ? (total === 0 ? 0 : (page - 1) * pageSize + 1) : 0;
const end = hasKnownTotal ? Math.min(total, page * pageSize) : Number(itemCount || 0);
const nextDisabled = hasKnownTotal ? page >= 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 (
<div className="pagination-bar">
<div className="pagination-bar__meta">
{showPageSize ? (
<span className="pagination-bar__page-size-wrap">
<span>每页</span>
<TextField
className="pagination-bar__page-size"
inputProps={{ "aria-label": "每页条数" }}
select
size="small"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
</span>
) : null}
<span>{showPageActions && hasKnownTotal ? `${start}-${end} / ${total}` : pageSummary}</span>
<span>{pageSummary}</span>
</div>
{showPageActions ? (
<div className="pagination-bar__actions">
<IconButton disabled={page <= 1} label="上一页" onClick={() => onPageChange(page - 1)}>
<KeyboardArrowLeft fontSize="small" />
</IconButton>
<span>{page} / {resolvedTotalPages}</span>
<IconButton disabled={nextDisabled} label="下一页" onClick={() => onPageChange(page + 1)}>
<KeyboardArrowRight fontSize="small" />
</IconButton>
</div>
) : null}
</div>
);
}

View File

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

View File

@ -1 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});