Files
shaguabijia-admin-web/src/app/(main)/users/page.tsx
T
2026-06-21 00:15:17 +08:00

389 lines
14 KiB
TypeScript

'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 } 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 type { UserListItem } from '@/lib/types';
const { RangePicker } = DatePicker;
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
type SortField = 'id' | 'created_at' | 'last_login_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<string | undefined>();
const [channel, setChannel] = useState<string | undefined>();
const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null);
const [loginRange, setLoginRange] = 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, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<UserListItem>('/admin/api/users', filters);
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
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_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
last_login_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<UserListItem> | SorterResult<UserListItem>[],
) => {
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<unknown>,
) => {
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<UserListItem> = [
{ 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) => <Tag color={STATUS_COLOR[s]}>{s}</Tag>,
},
{
title: '注册时间',
dataIndex: 'created_at',
width: 160,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '最近登录',
dataIndex: 'last_login_at',
width: 160,
sorter: true,
sortOrder: sortOrderOf('last_login_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
key: 'op',
fixed: 'right',
width: 300,
render: (_: unknown, u: UserListItem) => (
<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>}
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
)}
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleDebugTrace(u)}>
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
</a>
)}
{canStatus &&
u.status !== 'deleted' &&
(u.force_onboarding ? (
<span style={{ color: '#fa8c16' }}>
<a onClick={() => setForceOnboarding(u, false)}></a>
</span>
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
</Space>
),
},
];
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="手机号前缀"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Input
placeholder="昵称(模糊)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 110 }}
options={[
{ value: 'active', label: 'active' },
{ value: 'disabled', label: 'disabled' },
{ value: 'deleted', label: 'deleted' },
]}
/>
<Select
placeholder="渠道"
value={channel}
onChange={setChannel}
allowClear
style={{ width: 110 }}
options={[
{ value: 'sms', label: 'sms' },
{ value: 'wechat', label: 'wechat' },
]}
/>
<RangePicker
placeholder={['注册起', '注册止']}
value={regRange}
onChange={(v) => setRegRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<RangePicker
placeholder={['最近登录起', '最近登录止']}
value={loginRange}
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
</Space>
{canStatus && selectedUsers.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}>
{selectedUsers.length} (active {selectedActive.length}disabled{' '}
{selectedDisabled.length})
</span>
<Button danger disabled={!selectedActive.length} onClick={() => bulkStatus('disabled')}>
</Button>
<Button disabled={!selectedDisabled.length} onClick={() => bulkStatus('active')}>
</Button>
<Button onClick={() => bulkDebugTrace(true)}></Button>
<Button onClick={() => bulkDebugTrace(false)}></Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canStatus
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (u) => ({ 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: 1160 }}
onChange={onTableChange}
/>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
</div>
);
}