'use client'; import { useEffect, useState } from 'react'; import type { Key } from 'react'; import { useRouter } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; import type { SorterResult } from 'antd/es/table/interface'; import { App, Button, DatePicker, Input, Select, Space, Table, Tag, Tooltip } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; import { usePagedList } from '@/lib/usePagedList'; import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; import { DeleteUserModal } from '@/components/DeleteUserModal'; import type { UserListItem } from '@/lib/types'; const { RangePicker } = DatePicker; const STATUS_COLOR: Record = { active: 'green', disabled: 'red', deleted: 'default' }; type SortField = 'id' | 'created_at' | 'last_active_at'; export default function UsersPage() { const router = useRouter(); const { message, modal } = App.useApp(); // 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交 const [phone, setPhone] = useState(''); const [nickname, setNickname] = useState(''); const [status, setStatus] = useState(); const [channel, setChannel] = useState(); const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null); const [loginRange, setLoginRange] = useState<[Dayjs, Dayjs] | null>(null); const [applied, setApplied] = useState>({}); // 排序(服务端):点列头即时生效,与「查询」按钮分开 const [sortBy, setSortBy] = useState('id'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; const filterKey = JSON.stringify(filters); const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = usePagedList('/admin/api/users', filters); const canCoins = canDo(['finance']); const canStatus = canDo(['operator']); const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐) const [coinUser, setCoinUser] = useState(null); const [cashUser, setCashUser] = useState(null); const [delUser, setDelUser] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 筛选/排序变化后清空已选(避免选中项跨筛选错位) useEffect(() => { setSelectedRowKeys([]); }, [filterKey]); const search = () => setApplied({ phone: phone || undefined, nickname: nickname || undefined, status, register_channel: channel || undefined, created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined, created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined, last_active_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined, last_active_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined, }); const resetFilters = () => { setPhone(''); setNickname(''); setStatus(undefined); setChannel(undefined); setRegRange(null); setLoginRange(null); setSortBy('id'); setSortOrder('desc'); setApplied({}); }; const onTableChange = ( _pagination: unknown, _filters: unknown, sorter: SorterResult | SorterResult[], ) => { 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 { // 取消排序 → 回默认(ID 倒序) setSortBy('id'); setSortOrder('desc'); } }; const sortOrderOf = (field: SortField) => sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; const toggleStatus = (u: UserListItem) => { const next = u.status === 'active' ? 'disabled' : 'active'; modal.confirm({ title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`, onOk: async () => { try { await api.post(`/admin/api/users/${u.id}/status`, { status: next }); message.success('已更新状态'); reload(); } catch (e) { message.error(errMsg(e)); } }, }); }; const toggleDebugTrace = (u: UserListItem) => { const next = !u.debug_trace_enabled; modal.confirm({ title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`, content: next ? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障' : undefined, onOk: async () => { try { await api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled: next }); message.success('已更新调试链接权限'); reload(); } catch (e) { message.error(errMsg(e)); } }, }); }; // 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。 // 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。 const setForceOnboarding = (u: UserListItem, enable: boolean) => { modal.confirm({ title: enable ? `确认让用户 ${u.phone} 重看一次新手引导?` : `撤销用户 ${u.phone} 的「重看新手引导」?`, content: enable ? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。' : '该用户尚未重看,撤销后下次打开 App 不再触发。', onOk: async () => { try { await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable }); message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销'); reload(); } catch (e) { message.error(errMsg(e)); } }, }); }; const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id)); const selectedActive = selectedUsers.filter((u) => u.status === 'active'); const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled'); // 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 const runBulk = async ( targets: UserListItem[], label: string, call: (u: UserListItem) => Promise, ) => { const results = await Promise.allSettled(targets.map(call)); const ok = results.filter((r) => r.status === 'fulfilled').length; const fail = results.length - ok; if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`); else message.success(`${label}完成:${ok} 个`); setSelectedRowKeys([]); reload(); }; const bulkStatus = (target: 'disabled' | 'active') => { const targets = target === 'disabled' ? selectedActive : selectedDisabled; const label = target === 'disabled' ? '批量封禁' : '批量解封'; if (!targets.length) { message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`); return; } modal.confirm({ title: `确认${label} ${targets.length} 个用户?`, content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效', okButtonProps: { danger: target === 'disabled' }, onOk: () => runBulk(targets, label, (u) => api.post(`/admin/api/users/${u.id}/status`, { status: target }), ), }); }; const bulkDebugTrace = (enabled: boolean) => { // 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求 const targets = selectedUsers.filter( (u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled, ); const label = enabled ? '批量开调试链接' : '批量关调试链接'; if (!targets.length) { message.warning('没有需要变更的选中用户'); return; } modal.confirm({ title: `确认${label} ${targets.length} 个用户?`, onOk: () => runBulk(targets, label, (u) => api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }), ), }); }; const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') }, { title: '手机号', dataIndex: 'phone', width: 130 }, { title: '昵称', dataIndex: 'nickname', width: 150, ellipsis: true, render: (v: string | null) => v || '-', }, { title: '渠道', dataIndex: 'register_channel', width: 90 }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s}, }, { title: '注册时间', dataIndex: 'created_at', width: 160, sorter: true, sortOrder: sortOrderOf('created_at'), render: (v: string) => formatUtcTime(v), }, { // 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券),与大盘 DAU/留存活跃口径一致(2026-07-05) title: ( 最近活跃 ), dataIndex: 'last_active_at', width: 160, sorter: true, // 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡 showSorterTooltip: false, sortOrder: sortOrderOf('last_active_at'), render: (v: string | null, u: UserListItem) => formatUtcTime(v ?? u.last_login_at), }, { title: '操作', key: 'op', fixed: 'right', width: 340, render: (_: unknown, u: UserListItem) => ( e.stopPropagation()}> router.push(`/users/${u.id}`)}>详情 {canCoins && setCoinUser(u)}>调金币} {canCoins && setCashUser(u)}>调现金} {canStatus && u.status !== 'deleted' && ( toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'} )} {canStatus && u.status !== 'deleted' && ( toggleDebugTrace(u)}> {u.debug_trace_enabled ? '关调试链接' : '开调试链接'} )} {canStatus && u.status !== 'deleted' && (u.force_onboarding ? ( 待重看 setForceOnboarding(u, false)}>撤销 ) : ( setForceOnboarding(u, true)}>开启新手引导 ))} {canDelete && u.status !== 'deleted' && ( setDelUser(u)}> 注销 )} ), }, ]; return (

用户管理

setPhone(e.target.value)} onPressEnter={search} allowClear style={{ width: 150 }} /> setNickname(e.target.value)} onPressEnter={search} allowClear style={{ width: 150 }} /> setRegRange(v as [Dayjs, Dayjs] | null)} allowClear /> setLoginRange(v as [Dayjs, Dayjs] | null)} allowClear /> {canStatus && selectedUsers.length > 0 && ( 已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '} {selectedDisabled.length}) )} ({ disabled: u.status === 'deleted' }), } : undefined } columns={columns} dataSource={items} loading={loading} pagination={{ current: page, pageSize, total, showSizeChanger: true, showTotal: (t) => `共 ${t} 个用户`, onChange: onPageChange, }} scroll={{ x: 1200 }} onChange={onTableChange} /> setCoinUser(null)} /> setCashUser(null)} /> setDelUser(null)} onDone={reload} /> ); }