优化表单
This commit is contained in:
parent
f2c7ccf7f3
commit
03869b3a37
@ -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}": {
|
||||
|
||||
@ -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" };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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: "" });
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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 = () => ({
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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 = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"];
|
||||
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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", "成功"],
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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), {
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" });
|
||||
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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,
|
||||
|
||||
14
src/shared/api/generated/schema.d.ts
vendored
14
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user