feat(catalog): product visibility whitelist by phone
Admin can limit ON_SALE products to test phones; C-end filters by user phone. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
@@ -32,6 +32,8 @@ type Row = {
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -51,6 +53,8 @@ type ProductFormValues = {
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -59,6 +63,13 @@ 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;
|
||||
return {
|
||||
@@ -66,6 +77,8 @@ function mapDetailToForm(d: Record<string, unknown>) {
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
|
||||
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
|
||||
visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled,
|
||||
visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[],
|
||||
storyTitle: detail.storyTitle ?? '',
|
||||
storyText: detail.storyText ?? '',
|
||||
features: detail.features?.length
|
||||
@@ -91,6 +104,10 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
skuCode: v.skuCode,
|
||||
barcode69: v.barcode69,
|
||||
@@ -103,6 +120,8 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -183,7 +202,88 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
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 (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
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>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
@@ -223,6 +323,7 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
<VisibilityWhitelistFields form={form} />
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
@@ -264,7 +365,7 @@ export default function ProductsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = useMemo(() => [
|
||||
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
|
||||
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||
@@ -274,6 +375,13 @@ export default function ProductsPage() {
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
) },
|
||||
{
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '现场取货',
|
||||
dataIndex: 'allowOnSitePickup',
|
||||
@@ -305,7 +413,7 @@ export default function ProductsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
], [detail, editForm]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -323,7 +431,7 @@ export default function ProductsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1140 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1240 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
@@ -345,7 +453,7 @@ export default function ProductsPage() {
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
@@ -372,11 +480,12 @@ export default function ProductsPage() {
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
|
||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
|
||||
Reference in New Issue
Block a user