Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de58e69933 | |||
| a84c4570ba | |||
| b1270481aa | |||
| 8ec2e99366 |
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
|
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import type { AdminInfo } from '@/lib/types';
|
import type { AdminInfo } from '@/lib/types';
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
@@ -11,7 +12,8 @@ const ROLES = [
|
|||||||
{ value: 'finance', label: 'finance' },
|
{ value: 'finance', label: 'finance' },
|
||||||
{ value: 'operator', label: 'operator' },
|
{ 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() {
|
export default function AdminsPage() {
|
||||||
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
||||||
|
|||||||
@@ -3,19 +3,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { Button, Input, Space, Table, Tag } from 'antd';
|
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';
|
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() {
|
export default function AuditLogsPage() {
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||||
const { items, nextCursor, loading, loadMore } = useCursorList<AuditLog>(
|
const { items, total, page, pageSize, loading, onChange: onPageChange } =
|
||||||
'/admin/api/audit-logs',
|
usePagedList<AuditLog>('/admin/api/audit-logs', filters, 50);
|
||||||
filters,
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
|
|
||||||
const search = () => setFilters({ action: action || undefined });
|
const search = () => setFilters({ action: action || undefined });
|
||||||
|
|
||||||
@@ -59,14 +58,16 @@ export default function AuditLogsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 1000 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
|
||||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
|
||||||
{nextCursor ? '加载更多' : '没有更多了'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
|
|
||||||
interface StatItem {
|
interface StatItem {
|
||||||
metric: string; // help_users / total_compares / total_saved
|
metric: string; // help_users / total_compares / total_saved
|
||||||
@@ -601,7 +602,7 @@ export default function HomeStatsConfig() {
|
|||||||
</span>
|
</span>
|
||||||
{it.updated_at && (
|
{it.updated_at && (
|
||||||
<span style={{ color: '#bbb' }}>
|
<span style={{ color: '#bbb' }}>
|
||||||
更新于 {new Date(it.updated_at).toLocaleString('zh-CN')}
|
更新于 {formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
|
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import type { DeviceOnboardingItem } from '@/lib/types';
|
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> = {};
|
const EMPTY: Record<string, unknown> = {};
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
|
|||||||
@@ -1,23 +1,100 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Key } from 'react';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import type { Feedback } from '@/lib/types';
|
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() {
|
export default function FeedbacksPage() {
|
||||||
|
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||||
const [status, setStatus] = useState<string | undefined>();
|
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>(
|
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
|
||||||
'/admin/api/feedbacks',
|
'/admin/api/feedbacks',
|
||||||
filters,
|
filters,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canHandle = canDo(['operator']);
|
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) => {
|
const handle = async (f: Feedback) => {
|
||||||
try {
|
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> = [
|
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: 'user_id', width: 80 },
|
||||||
{ title: '内容', dataIndex: 'content' },
|
{ 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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
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: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
@@ -57,23 +191,75 @@ export default function FeedbacksPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>反馈工单</h2>
|
<h2>反馈工单</h2>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
<Select
|
<Select
|
||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(v) => {
|
onChange={setStatus}
|
||||||
setStatus(v);
|
|
||||||
setFilters({ status: v });
|
|
||||||
}}
|
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 140 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'new', label: '待处理' },
|
{ value: 'new', label: '待处理' },
|
||||||
{ value: 'handled', 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>
|
</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' }}>
|
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||||
{nextCursor ? '加载更多' : '没有更多了'}
|
{nextCursor ? '加载更多' : '没有更多了'}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -65,10 +65,8 @@ export default function PriceReportsPage() {
|
|||||||
const filters: Record<string, unknown> = {};
|
const filters: Record<string, unknown> = {};
|
||||||
if (activeStatus !== 'all') filters.status = activeStatus;
|
if (activeStatus !== 'all') filters.status = activeStatus;
|
||||||
|
|
||||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<PriceReport>(
|
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||||
'/admin/api/price-reports',
|
usePagedList<PriceReport>('/admin/api/price-reports', filters);
|
||||||
filters,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canReview = canDo(['operator']);
|
const canReview = canDo(['operator']);
|
||||||
|
|
||||||
@@ -277,14 +275,16 @@ export default function PriceReportsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
|
||||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
|
||||||
{nextCursor ? '加载更多' : '没有更多了'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import dayjs, { type Dayjs } from 'dayjs';
|
|||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
import type { UserListItem } from '@/lib/types';
|
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 filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||||
const filterKey = JSON.stringify(filters);
|
const filterKey = JSON.stringify(filters);
|
||||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
|
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||||
'/admin/api/users',
|
usePagedList<UserListItem>('/admin/api/users', filters);
|
||||||
filters,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canCoins = canDo(['finance']);
|
const canCoins = canDo(['finance']);
|
||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
@@ -190,8 +188,14 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<UserListItem> = [
|
const columns: ColumnsType<UserListItem> = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
|
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
|
||||||
{ title: '手机号', dataIndex: 'phone' },
|
{ title: '手机号', dataIndex: 'phone', width: 130 },
|
||||||
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
{
|
||||||
|
title: '昵称',
|
||||||
|
dataIndex: 'nickname',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -219,9 +223,9 @@ export default function UsersPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 230,
|
width: 300,
|
||||||
render: (_: unknown, u: UserListItem) => (
|
render: (_: unknown, u: UserListItem) => (
|
||||||
<Space onClick={(e) => e.stopPropagation()}>
|
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
||||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||||
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||||
{canCoins && <a onClick={() => setCashUser(u)}>调现金</a>}
|
{canCoins && <a onClick={() => setCashUser(u)}>调现金</a>}
|
||||||
@@ -333,15 +337,17 @@ export default function UsersPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={{
|
||||||
scroll={{ x: 1100 }}
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 个用户`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1160 }}
|
||||||
onChange={onTableChange}
|
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)} />
|
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||||
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs';
|
|||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import type {
|
import type {
|
||||||
AuditLog,
|
AuditLog,
|
||||||
CashTxn,
|
CashTxn,
|
||||||
@@ -181,7 +181,6 @@ export default function WithdrawsPage() {
|
|||||||
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
|
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
|
||||||
const [searchDraft, setSearchDraft] = useState('');
|
const [searchDraft, setSearchDraft] = useState('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
@@ -202,11 +201,8 @@ export default function WithdrawsPage() {
|
|||||||
filters.sort_by = sortBy;
|
filters.sort_by = sortBy;
|
||||||
filters.sort_order = sortOrder;
|
filters.sort_order = sortOrder;
|
||||||
const filterKey = JSON.stringify(filters);
|
const filterKey = JSON.stringify(filters);
|
||||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
|
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||||
'/admin/api/withdraws',
|
usePagedList<WithdrawOrder>('/admin/api/withdraws', filters, 20);
|
||||||
filters,
|
|
||||||
pageSize,
|
|
||||||
);
|
|
||||||
const canManage = canDo(['finance']);
|
const canManage = canDo(['finance']);
|
||||||
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
|
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
|
||||||
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
|
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
|
||||||
@@ -290,7 +286,7 @@ export default function WithdrawsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
}, [filterKey, pageSize]);
|
}, [filterKey, page, pageSize]);
|
||||||
|
|
||||||
const refreshAfterChange = async (outBillNo?: string) => {
|
const refreshAfterChange = async (outBillNo?: string) => {
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
@@ -887,31 +883,6 @@ export default function WithdrawsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</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
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
rowSelection={
|
rowSelection={
|
||||||
@@ -928,18 +899,21 @@ export default function WithdrawsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
scroll={{ x: 1350 }}
|
scroll={{ x: 1350 }}
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
onClick: () => openDetail(record),
|
onClick: () => openDetail(record),
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
|
||||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
|
||||||
{nextCursor ? `继续加载 ${pageSize} 条` : '没有更多了'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface LoginResponse {
|
|||||||
export interface CursorPage<T> {
|
export interface CursorPage<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
next_cursor: number | null;
|
next_cursor: number | null;
|
||||||
|
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserListItem {
|
export interface UserListItem {
|
||||||
|
|||||||
@@ -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), // 写操作后刷新当前页
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user