36d00a9d68
Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #13 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
389 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|