Files
dukang/apps/admin-web/src/pages/TestWhitelistPage.tsx
T
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00

688 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
关联账号
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
编辑备注
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => ${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
白名单管理
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
短信不做真实验证
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
微信不做真实验证
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
支付不做真实验证
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
配置键:{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
从可见性白名单导入
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
添加手机号
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
查询
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary">加载中…</Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C 端用户({linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>门店账号({linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>合伙人({linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>门店({linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}