推荐商品

This commit is contained in:
liu
2026-08-31 22:21:59 +08:00
parent 6c809d893b
commit 9c59f3bc99
191 changed files with 2095 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
import createAxios from '@/utils/request';
/** 批量添加特价推荐商品(已在推荐中的自动跳过,返回 added/skipped 计数) */
export async function batchAddSpecials(product_ids: number[]) {
return createAxios<{ added: number; skipped: number }>({
url: '/client/special/batch',
method: 'post',
data: { product_ids },
});
}
/** 批量删除特价推荐 */
export async function batchDeleteSpecials(ids: number[]) {
return createAxios({
url: '/client/special/batch',
method: 'delete',
data: { ids },
});
}
+17
View File
@@ -0,0 +1,17 @@
import type IProduct from '@/domain/iProduct.ts';
/** 小程序首页特价推荐商品 */
export default interface IHomeSpecial {
id?: number;
product_id?: number;
product?: IProduct;
sort?: number;
status?: number;
created_at?: string;
updated_at?: string;
}
export const HOME_SPECIAL_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
};
+265
View File
@@ -0,0 +1,265 @@
import React, { useRef, useState } from 'react';
import { Button, Form, Image, message, Modal, Select, Tag, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import XinTable from '@/components/XinTable';
import AuthButton from '@/components/AuthButton';
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
import type IHomeSpecial from '@/domain/iHomeSpecial.ts';
import { HOME_SPECIAL_STATUS_MAP } from '@/domain/iHomeSpecial.ts';
import type IProduct from '@/domain/iProduct.ts';
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
import { getProductOptions } from '@/api/product/goods.ts';
import { batchAddSpecials, batchDeleteSpecials } from '@/api/client/special.ts';
const { Title, Text } = Typography;
/**
* 特价推荐商品配置(小程序首页)
*
* 仅标记推荐商品,不设特价字段:小程序端按门店客户等级价展示;
* 推荐停用或商品下架后不展示,排序越小越靠前。
*/
const HomeSpecialPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IHomeSpecial> | null>(null);
// ===== 批量删除 =====
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [batchDeleting, setBatchDeleting] = useState(false);
/** 批量删除(成功后清空勾选并刷新列表) */
const handleBatchDelete = () => {
window.$modal?.confirm({
title: `确定要删除选中的 ${selectedRowKeys.length} 条推荐吗?`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
setBatchDeleting(true);
try {
await batchDeleteSpecials(selectedRowKeys);
message.success('批量删除成功');
setSelectedRowKeys([]);
await tableRef.current?.reload();
} finally {
setBatchDeleting(false);
}
},
});
};
// ===== 批量添加 =====
const [addOpen, setAddOpen] = useState(false);
const [addSaving, setAddSaving] = useState(false);
const [addForm] = Form.useForm();
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
/** 打开添加弹窗(首次打开时加载上架商品选项) */
const openAdd = () => {
addForm.resetFields();
setAddOpen(true);
if (productOptions.length === 0) {
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
}
};
/** 提交批量添加(重复推荐后端自动跳过) */
const handleAddSave = async (values: { product_ids: number[] }) => {
setAddSaving(true);
try {
const res = await batchAddSpecials(values.product_ids);
const { added = 0, skipped = 0 } = res.data.data ?? {};
message.success(`已添加 ${added} 件推荐商品${skipped > 0 ? `${skipped} 件已在推荐中自动跳过` : ''}`);
setAddOpen(false);
await tableRef.current?.reload();
} finally {
setAddSaving(false);
}
};
const columns: XinTableColumn<IHomeSpecial>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center',
},
{
title: '商品图片',
dataIndex: 'image',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 100,
render: (_, record) => {
const url = record.product?.images_arr?.[0]?.preview_url;
if (!url) return '-';
return (
<Image
src={url}
width={60}
height={60}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
);
},
},
{
title: '商品名称',
dataIndex: 'keyword',
hideInForm: true,
valueType: 'text',
fieldProps: { placeholder: '按商品名称搜索' },
render: (_, record) => {
const product = record.product;
if (!product) return '-';
return `${product.name}${product.spec ? `${product.spec}` : ''}`;
},
},
{
title: '市场',
dataIndex: 'market',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => record.product?.market || '-',
},
{
title: '计价单位',
dataIndex: 'unit',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 90,
render: (_, record) => record.product?.unit || '-',
},
{
title: '商品状态',
dataIndex: 'product_status',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 100,
render: (_, record) => {
const item = PRODUCT_STATUS_MAP[record.product?.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{
title: '排序',
dataIndex: 'sort',
valueType: 'digit',
hideInSearch: true,
initialValue: 0,
fieldProps: { min: 0, precision: 0 },
align: 'center',
width: 80,
},
{
title: '状态',
dataIndex: 'status',
valueType: 'radioButton',
initialValue: 1,
fieldProps: {
options: [
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
],
},
render: (_, record) => {
const item = HOME_SPECIAL_STATUS_MAP[record.status ?? 1];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
width: 90,
},
{
title: '创建时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const tableProps: XinTableProps<IHomeSpecial> = {
api: '/client/special',
columns,
rowKey: 'id',
accessName: 'client.special',
tableRef,
addShow: false,
rowSelection: {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
},
actionBarRender: (dom) => [
dom.search,
<AuthButton key="add" auth="client.special.create">
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
</Button>
</AuthButton>,
<AuthButton key="batchDelete" auth="client.special.delete">
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchDeleting}
onClick={handleBatchDelete}
>
{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
</Button>
</AuthButton>,
],
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 640 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
</Text>
</div>
<XinTable<IHomeSpecial> {...tableProps} />
<Modal
title="添加推荐商品"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => addForm.submit()}
confirmLoading={addSaving}
okText="添加"
destroyOnHidden
>
<Form form={addForm} layout="vertical" onFinish={handleAddSave} preserve={false}>
<Form.Item
label="商品"
name="product_ids"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
mode="multiple"
showSearch={{ optionFilterProp: 'label' }}
placeholder="搜索并选择商品(可多选)"
options={productOptions.map((p) => ({
value: p.id!,
label: `${p.name}${p.spec ? `${p.spec}` : ''}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</>
);
};
export default HomeSpecialPage;