Compare commits

...

4 Commits

Author SHA1 Message Date
OuYingJun1024 de58e69933 feat(admin-web): 主列表页改页码分页(usePagedList + antd pagination)
用户/提现/上报/审计日志四页从「加载更多」改为页码分页:
- 新增 usePagedList hook:页码→offset、暴露 total,配 antd Table pagination;
  与 useCursorList(加载更多)并存,详情子表/反馈页仍用后者
- CursorPage 类型加 total?
- 四页接入 pagination(页码/跳页/共 N 条/showSizeChanger);提现页移除自定义
  「每次加载」选择器,统一用 showSizeChanger(20/50/100)
- 反馈页本轮不动(后端反馈接口在 feat/feedback-iteration 迭代中)

tsc --noEmit 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:37:38 +08:00
OuYingJun1024 a84c4570ba fix(admin-web): 用户列表列宽——手机号/昵称定宽,操作列加宽防换行
手机号/昵称原无 width,宽屏下吃满剩余空间显得失衡;操作列 fixed 230px
装不下 super_admin 的 5 个链接(详情/调金币/调现金/封禁/关调试链接)→ 换行。
- 手机号 width 130、昵称 width 150 + ellipsis(超长省略)
- 操作列 230→300 + Space wrap={false},5 链接稳定一行
- scroll.x 1100→1160 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:02:56 +08:00
OuYingJun1024 b1270481aa feat(admin-web): 反馈工单页加截图列 + 排序/筛选/批量标记处理
- 截图列:缩略图 + Image.PreviewGroup 预览,URL 走 NEXT_PUBLIC_MEDIA_BASE(本地 :8770)
- 排序:ID/时间 列头服务端排序
- 筛选:状态 + 用户ID + 内容模糊 + 提交时间范围(草稿态 + 查询/重置)
- 批量:勾选 new 态 → 批量标记处理(循环 /handle + Promise.allSettled)
- 对齐用户页交互模式;依赖 app-server list_feedbacks 新增的 sort/filter 参数

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:37:22 +08:00
OuYingJun1024 8ec2e99366 fix(admin-web): 时间渲染收口到 formatUtcTime,消灭 new Date().toLocaleString
review 发现 4 处仍用 new Date(v).toLocaleString 渲染后端 UTC 口径时间戳——
正是 lib/format.ts 头注释明令消灭的反模式(SQLite 本地/非中国浏览器会差 8h,
生产靠 +00:00 偏移侥幸正确)。统一改用 formatUtcTime 按北京显示:
- admins: last_login_at
- audit-logs: created_at
- devices: completed_at
- dashboard/HomeStatsConfig: ops_stat_config.updated_at
均为 server_default now()/datetime.now(utc) 的 UTC 口径字段。
HomeStatsConfig 的「下个更新时刻」用 epoch ms + 强制 Asia/Shanghai,正确,不动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:47 +08:00
10 changed files with 344 additions and 96 deletions
+3 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdminInfo } from '@/lib/types';
const ROLES = [
@@ -11,7 +12,8 @@ const ROLES = [
{ value: 'finance', label: 'finance' },
{ value: 'operator', label: 'operator' },
];
const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未');
// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
export default function AdminsPage() {
const [admins, setAdmins] = useState<AdminInfo[]>([]);
+14 -13
View File
@@ -3,19 +3,18 @@
import { useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Input, Space, Table, Tag } from 'antd';
import { useCursorList } from '@/lib/useCursorList';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { AuditLog } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
// 审计 created_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string) => formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss');
export default function AuditLogsPage() {
const [action, setAction] = useState('');
const [filters, setFilters] = useState<Record<string, unknown>>({});
const { items, nextCursor, loading, loadMore } = useCursorList<AuditLog>(
'/admin/api/audit-logs',
filters,
50,
);
const { items, total, page, pageSize, loading, onChange: onPageChange } =
usePagedList<AuditLog>('/admin/api/audit-logs', filters, 50);
const search = () => setFilters({ action: action || undefined });
@@ -59,14 +58,16 @@ export default function AuditLogsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1000 }}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
</div>
);
}
+2 -1
View File
@@ -19,6 +19,7 @@ import {
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
interface StatItem {
metric: string; // help_users / total_compares / total_saved
@@ -601,7 +602,7 @@ export default function HomeStatsConfig() {
</span>
{it.updated_at && (
<span style={{ color: '#bbb' }}>
{new Date(it.updated_at).toLocaleString('zh-CN')}
{formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')}
</span>
)}
</Space>
+3 -1
View File
@@ -4,10 +4,12 @@ import type { ColumnsType } from 'antd/es/table';
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import type { DeviceOnboardingItem } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
// completed_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string) => formatUtcTime(v);
const EMPTY: Record<string, unknown> = {};
export default function DevicesPage() {
+200 -14
View File
@@ -1,23 +1,100 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { Key } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Select, Space, Table, Tag, message } from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
DatePicker,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import type { Feedback } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
const { Text } = Typography;
const { RangePicker } = DatePicker;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
type SortField = 'id' | 'created_at';
export default function FeedbacksPage() {
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
const [userId, setUserId] = useState('');
const [content, setContent] = useState('');
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
const [applied, setApplied] = useState<Record<string, unknown>>({});
// 排序(服务端):点列头即时生效,与「查询」按钮分开
const [sortBy, setSortBy] = useState<SortField>('id');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
'/admin/api/feedbacks',
filters,
);
const canHandle = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey]);
const search = () =>
setApplied({
status,
user_id: userId.trim() ? Number(userId.trim()) : undefined,
content: content.trim() || undefined,
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setStatus(undefined);
setUserId('');
setContent('');
setCreatedRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<Feedback> | SorterResult<Feedback>[],
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
if (s && s.order && s.field) {
setSortBy(s.field as SortField);
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortBy('id');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const handle = async (f: Feedback) => {
try {
@@ -29,18 +106,75 @@ export default function FeedbacksPage() {
}
};
const selectedNew = items.filter(
(f) => selectedRowKeys.includes(f.id) && f.status === 'new',
);
// 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
const bulkHandle = () => {
if (!selectedNew.length) {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
Modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
const results = await Promise.allSettled(
selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)),
);
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`);
else message.success(`批量标记处理完成:${ok}`);
setSelectedRowKeys([]);
reload();
},
});
};
const columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{ title: '内容', dataIndex: 'content' },
{ title: '联系方式', dataIndex: 'contact', width: 140 },
{
title: '截图',
dataIndex: 'images',
width: 180,
render: (imgs: string[] | null) =>
imgs && imgs.length ? (
<Image.PreviewGroup>
<Space size={4} wrap>
{imgs.map((src, i) => (
<Image
key={i}
src={mediaUrl(src)}
width={44}
height={44}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary"></Text>
),
},
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
},
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
key: 'op',
@@ -57,23 +191,75 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
value={status}
onChange={(v) => {
setStatus(v);
setFilters({ status: v });
}}
onChange={setStatus}
allowClear
style={{ width: 140 }}
style={{ width: 120 }}
options={[
{ value: 'new', label: '待处理' },
{ value: 'handled', label: '已处理' },
]}
/>
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
onPressEnter={search}
allowClear
style={{ width: 120 }}
/>
<Input
placeholder="内容(模糊)"
value={content}
onChange={(e) => setContent(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<RangePicker
placeholder={['提交起', '提交止']}
value={createdRange}
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
{canHandle && selectedNew.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}> {selectedNew.length} </span>
<Button type="primary" onClick={bulkHandle}>
</Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canHandle
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
onChange={onTableChange}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
+11 -11
View File
@@ -20,7 +20,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
const { Text } = Typography;
@@ -65,10 +65,8 @@ export default function PriceReportsPage() {
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, nextCursor, loading, loadMore, reload } = useCursorList<PriceReport>(
'/admin/api/price-reports',
filters,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const canReview = canDo(['operator']);
@@ -277,14 +275,16 @@ export default function PriceReportsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1100 }}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
</Card>
<Modal
+22 -16
View File
@@ -10,7 +10,7 @@ import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import type { UserListItem } from '@/lib/types';
@@ -37,10 +37,8 @@ export default function UsersPage() {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
'/admin/api/users',
filters,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<UserListItem>('/admin/api/users', filters);
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
@@ -190,8 +188,14 @@ export default function UsersPage() {
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '手机号', dataIndex: 'phone' },
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{
title: '昵称',
dataIndex: 'nickname',
width: 150,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
{
title: '状态',
@@ -219,9 +223,9 @@ export default function UsersPage() {
title: '操作',
key: 'op',
fixed: 'right',
width: 230,
width: 300,
render: (_: unknown, u: UserListItem) => (
<Space onClick={(e) => e.stopPropagation()}>
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
<a onClick={() => router.push(`/users/${u.id}`)}></a>
{canCoins && <a onClick={() => setCoinUser(u)}></a>}
{canCoins && <a onClick={() => setCashUser(u)}></a>}
@@ -333,15 +337,17 @@ export default function UsersPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个用户`,
onChange: onPageChange,
}}
scroll={{ x: 1160 }}
onChange={onTableChange}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
+13 -39
View File
@@ -39,7 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import type {
AuditLog,
CashTxn,
@@ -181,7 +181,6 @@ export default function WithdrawsPage() {
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
const [rejectReason, setRejectReason] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [pageSize, setPageSize] = useState(20);
const [searchDraft, setSearchDraft] = useState('');
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
@@ -202,11 +201,8 @@ export default function WithdrawsPage() {
filters.sort_by = sortBy;
filters.sort_order = sortOrder;
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
'/admin/api/withdraws',
filters,
pageSize,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<WithdrawOrder>('/admin/api/withdraws', filters, 20);
const canManage = canDo(['finance']);
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
@@ -290,7 +286,7 @@ export default function WithdrawsPage() {
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey, pageSize]);
}, [filterKey, page, pageSize]);
const refreshAfterChange = async (outBillNo?: string) => {
setSelectedRowKeys([]);
@@ -887,31 +883,6 @@ export default function WithdrawsPage() {
</Space>
</Space>
)}
<Space
wrap
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<Text type="secondary">
{items.length} {nextCursor ? ',还有更多' : ',已到最后一页'}
</Text>
<Space>
<Text type="secondary"></Text>
<Select
value={pageSize}
style={{ width: 96 }}
onChange={(value: number) => setPageSize(value)}
options={[
{ value: 20, label: '20 条' },
{ value: 50, label: '50 条' },
{ value: 100, label: '100 条' },
]}
/>
</Space>
</Space>
<Table
rowKey="id"
rowSelection={
@@ -928,18 +899,21 @@ export default function WithdrawsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1350 }}
onRow={(record) => ({
onClick: () => openDetail(record),
style: { cursor: 'pointer' },
})}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? `继续加载 ${pageSize}` : '没有更多了'}
</Button>
</div>
</Card>
<Drawer
+1
View File
@@ -19,6 +19,7 @@ export interface LoginResponse {
export interface CursorPage<T> {
items: T[];
next_cursor: number | null;
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
}
export interface UserListItem {
+75
View File
@@ -0,0 +1,75 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import type { CursorPage } from './types';
/**
* 页码分页列表 hook(对接后端 offset 分页 + total)。
* 与 useCursorList(「加载更多」)并存:需要页码/跳页/共 N 条的主列表页用本 hook。
*
* 约定:后端 cursor 即 offset,本 hook 按 (page-1)*pageSize 计算;CursorPage.total 为符合
* 筛选条件的总条数(供 antd Table pagination 渲染)。filters 变化自动回第 1 页重载。
*/
export function usePagedList<T>(
url: string,
filters: Record<string, unknown>,
initialPageSize = 20,
) {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
const filtersKey = JSON.stringify(filters);
const load = useCallback(
async (p: number, ps: number) => {
const seq = requestSeq.current + 1;
requestSeq.current = seq;
setLoading(true);
try {
const params = { ...JSON.parse(filtersKey), limit: ps, cursor: (p - 1) * ps };
const { data } = await api.get<CursorPage<T>>(url, { params });
if (requestSeq.current !== seq) return; // 丢弃过期响应(快速切页/筛选)
setItems(data.items);
setTotal(data.total ?? 0);
} finally {
if (requestSeq.current === seq) setLoading(false);
}
},
[url, filtersKey],
);
// 筛选变化:回第 1 页重载
useEffect(() => {
setPage(1);
load(1, pageSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load]);
// antd Table pagination.onChange(page, pageSize):页变或每页条数变都走这里
const onChange = (p: number, ps: number) => {
if (ps !== pageSize) {
// 每页条数变 → 回第 1 页,避免越界空页
setPageSize(ps);
setPage(1);
load(1, ps);
} else {
setPage(p);
load(p, ps);
}
};
return {
items,
total,
page,
pageSize,
loading,
onChange,
reload: () => load(page, pageSize), // 写操作后刷新当前页
};
}