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:
2026-08-07 15:46:23 +08:00
parent 10fa361983
commit b626db5d84
47 changed files with 1823 additions and 308 deletions
+37 -83
View File
@@ -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>
</>
),
},