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.
This commit is contained in:
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||
@@ -136,6 +137,7 @@ export default function App() {
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/test-whitelist': 'test_whitelist',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
|
||||
@@ -167,6 +167,7 @@ export type AdminUserRow = {
|
||||
sourceLabel: string | null;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -59,6 +59,7 @@ type Row = {
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
@@ -225,6 +226,7 @@ export default function OrdersPage() {
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -432,7 +434,17 @@ export default function OrdersPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '城市',
|
||||
width: 90,
|
||||
@@ -600,6 +612,9 @@ export default function OrdersPage() {
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
@@ -67,13 +67,6 @@ type ProductFormValues = {
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
type UserPickRow = {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
userNo?: string;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
const row = d as Row;
|
||||
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
allowCrossCityDelivery:
|
||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
}
|
||||
|
||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -419,8 +348,8 @@ export default function ProductsPage() {
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
@@ -14,6 +14,7 @@ type Row = {
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<span>
|
||||
{v}
|
||||
{row.isTest ? (
|
||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||
测试
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -15,6 +15,7 @@ type Row = {
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '绑定门店',
|
||||
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -277,36 +278,7 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
|
||||
);
|
||||
}
|
||||
|
||||
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
||||
|
||||
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -375,6 +315,7 @@ type StoreRow = {
|
||||
createdAt: string;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
account?: {
|
||||
@@ -424,8 +365,8 @@ export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
return init;
|
||||
@@ -434,12 +375,13 @@ export default function StoresPage() {
|
||||
'/admin/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.name) qs.set('name', String(filters.name));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -597,6 +539,7 @@ export default function StoresPage() {
|
||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||
? (d.visibilityPhones as string[])
|
||||
: [],
|
||||
isTest: !!d.isTest,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
@@ -637,9 +580,7 @@ export default function StoresPage() {
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
isTest: !!v.isTest,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
@@ -813,9 +754,6 @@ export default function StoresPage() {
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||
visibilityPhones: (values.visibilityPhones ?? [])
|
||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -839,7 +777,12 @@ export default function StoresPage() {
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
) },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
@@ -879,8 +822,8 @@ export default function StoresPage() {
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
@@ -939,6 +882,9 @@ export default function StoresPage() {
|
||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<StoreVisibilityWhitelistFields form={editForm} />
|
||||
<Form.Item
|
||||
name="isTest"
|
||||
label="测试门店"
|
||||
valuePropName="checked"
|
||||
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export default function UsersPage() {
|
||||
if (values.status !== undefined && values.status !== '') {
|
||||
qs.set('status', String(values.status));
|
||||
}
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -237,7 +238,17 @@ export default function UsersPage() {
|
||||
];
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
@@ -362,6 +373,9 @@ export default function UsersPage() {
|
||||
{ value: 0, label: '停用' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
|
||||
Reference in New Issue
Block a user