商品列表、分类管理、客户等级

This commit is contained in:
liu
2026-08-05 19:28:53 +08:00
parent dfa471bf22
commit 258df92e6c
36 changed files with 472 additions and 516 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
import createAxios from '@/utils/request';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type { IProductCategoryTree } from '@/domain/iProductCategory.ts';
/** 分类级联树(商品表单分类下拉、对账筛选用,仅启用分类) */
export async function getCategoryTree() {
return createAxios<IProductCategory[]>({
return createAxios<IProductCategoryTree[]>({
url: '/product/category/tree',
method: 'get',
});
+11
View File
@@ -19,6 +19,17 @@ export function getFileList(params: FileListParams) {
});
}
/**
* 获取文件详情
* @param id 文件ID
*/
export function getFileInfo(id: number) {
return createAxios<ISysFileInfo>({
url: `/system/file/list/show/${id}`,
method: 'get',
});
}
/**
* 获取回收站文件列表
* @param params 查询参数
@@ -6,6 +6,7 @@ import type { UploadFile, UploadProps } from 'antd';
import type { RcFile } from 'antd/es/upload';
import type { ImageUploaderProps } from './typings';
import type { ISysFileInfo } from '@/domain/iSysFile';
import { getFileInfo } from '@/api/system/sysFile';
import { useTranslation } from 'react-i18next';
/**
@@ -51,8 +52,29 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
return;
}
const valueArray = Array.isArray(value) ? value : [value];
const newFileList: UploadFile[] = valueArray.map(fileToList);
setFileList(newFileList);
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
if (changeType === 'id') {
const ids = valueArray as number[];
const idSet = new Set(fileList.map(obj => Number(obj.uid)));
// 过滤出不在 Set 中的数字
const missing = ids.filter(num => !idSet.has(Number(num)));
if (missing.length > 0) {
const fetchers = missing.map((id) => getFileInfo(id));
Promise.all(fetchers).then((resList) => {
const files = resList
.map((res) => res.data.data)
.filter(i => !!i)
.map(fileToList);
setFileList([...files, ...fileList]);
})
}
} else {
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
setFileList(newFileList);
}
}, [value]);
// 上传前校验
@@ -106,8 +128,16 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
// 处理文件列表变化
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
// 如果所有文件都被删除
if (newFileList.length === 0) return;
// 所有文件都被删除时,同步清空表单值,避免残留旧 id 被提交
if (newFileList.length === 0) {
setFileList([]);
if (mode === 'single') {
onChange?.(null);
} else {
onChange?.([]);
}
return;
}
setFileList(newFileList);
// 全部上传完成格式化图片列表
if (newFileList.every((file) => file.status === 'done' || file.status === 'error')) {
@@ -130,6 +160,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
}
// 上传失败的文件
const errorFiles = newFileList.filter((file) => file.status === 'error');
setFileList([...uploadedFiles.map(fileToList), ...errorFiles]);
}
};
@@ -15,7 +15,7 @@ export interface ImageUploaderProps {
* - 单选模式:ISysFileInfo | null
* - 多选模式:ISysFileInfo[]
*/
value?: ISysFileInfo | ISysFileInfo[] | null;
value?: ISysFileInfo | ISysFileInfo[] | number | number[] | null;
/**
* 赋值类型
@@ -0,0 +1,161 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { message, theme } from 'antd';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
import { uploadFile } from '@/api/system/sysFile';
import type { ISysFileInfo } from '@/domain/iSysFile';
import { useTranslation } from 'react-i18next';
import type { RichTextEditorProps } from './typings';
/**
* 富文本编辑器(wangEditor v5
* - 自定义图片上传:复用系统文件上传接口,校验参照 ImageUploader
* - 隐藏视频上传、表情、附件等复杂功能,仅保留基础排版与图片
*/
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
disabled = false,
height = 400,
placeholder,
groupId = 0,
maxSize = 5,
}) => {
const { t } = useTranslation();
const { token } = theme.useToken();
const [editor, setEditor] = useState<IDomEditor | null>(null);
// 自定义图片上传(校验逻辑参照 ImageUploader 组件)
const customUpload = useCallback(
async (file: File, insertFn: (src: string, alt: string, href: string) => void) => {
// 1. 文件类型校验
if (!file.type.startsWith('image/')) {
message.error(t('xin.form.richText.error.notImage'));
return;
}
// 2. 文件大小校验
if (file.size / 1024 / 1024 > maxSize) {
message.error(t('xin.form.richText.error.sizeExceeded', { maxSize }));
return;
}
// 3. 调用系统文件上传接口
try {
const res = await uploadFile(file, groupId);
const info = res.data.data as ISysFileInfo;
insertFn(info.preview_url || info.file_url || '', info.file_name || '', '');
} catch {
message.error(t('xin.form.richText.error.uploadFailed'));
}
},
[t, groupId, maxSize]
);
// 工具栏配置:只保留基础排版 + 图片,隐藏视频/表情/附件等复杂功能
const toolbarConfig: Partial<IToolbarConfig> = useMemo(
() => ({
toolbarKeys: [
'headerSelect',
'blockquote',
'|',
'bold',
'underline',
'italic',
'through',
'|',
'color',
'bgColor',
'|',
'bulletedList',
'numberedList',
'todo',
'|',
'justifyLeft',
'justifyCenter',
'justifyRight',
'|',
'insertLink',
'uploadImage',
'insertTable',
'|',
'undo',
'redo',
'fullScreen',
],
}),
[]
);
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = useMemo(
() => ({
placeholder,
MENU_CONF: {
uploadImage: {
// 自定义上传:覆盖默认的服务端上传方式
customUpload,
allowedFileTypes: ['image/*'],
maxFileSize: maxSize * 1024 * 1024,
// 禁用 base64 插入,粘贴/拖拽图片一律走自定义上传
base64LimitSize: 0,
},
},
}),
[placeholder, customUpload, maxSize]
);
// 禁用/启用编辑器
useEffect(() => {
if (editor == null) return;
if (disabled) {
editor.disable();
} else {
editor.enable();
}
}, [editor, disabled]);
// 组件卸载时销毁编辑器实例
useEffect(
() => () => {
if (editor == null) return;
editor.destroy();
setEditor(null);
},
[editor]
);
return (
<div
style={{
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
overflow: 'hidden',
}}
>
{!disabled && (
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode="default"
style={{ borderBottom: `1px solid ${token.colorBorder}` }}
/>
)}
<Editor
defaultConfig={editorConfig}
value={value}
onCreated={setEditor}
onChange={(ed) => onChange?.(ed.getHtml())}
mode="default"
style={{
height,
overflowY: 'hidden',
background: disabled ? token.colorBgLayout : undefined,
}}
/>
</div>
);
};
export default RichTextEditor;
@@ -0,0 +1,44 @@
/**
* 富文本编辑器组件属性
*/
export interface RichTextEditorProps {
/**
* 当前值(HTML 字符串)
*/
value?: string;
/**
* 值变化回调
* @param value HTML 字符串
*/
onChange?: (value?: string) => void;
/**
* 是否禁用
* @default false
*/
disabled?: boolean;
/**
* 编辑器高度 (px)
* @default 400
*/
height?: number;
/**
* 占位符
*/
placeholder?: string;
/**
* 上传文件分组 ID
* @default 0
*/
groupId?: number;
/**
* 图片大小限制 (MB)
* @default 5
*/
maxSize?: number;
}
+4 -1
View File
@@ -1,3 +1,5 @@
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
/** 客户等级 */
export default interface ICustomerLevel {
id?: number;
@@ -5,7 +7,8 @@ export default interface ICustomerLevel {
name?: string;
sort?: number;
status?: number;
icon?: number;
icon_id?: number;
icon?: ISysFileInfo;
icon_url?: string;
created_at?: string;
updated_at?: string;
+20 -3
View File
@@ -1,5 +1,6 @@
import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
/** 商品等级价格行 */
export interface IProductPrice {
@@ -12,24 +13,40 @@ export interface IProductPrice {
/** 商品档案 */
export default interface IProduct {
/** 商品ID */
id?: number;
/** 分类ID */
category_id?: number;
/** 供应商ID */
supplier_id?: number;
/** 商品名称 */
name?: string;
/** 规格/包规 */
spec?: string;
/** 商品等级 */
grade?: string;
/** 计价单位 */
unit?: string;
image?: string;
/** 封面 */
image_ids?: string;
images_arr?: ISysFileInfo[];
/** 商品图文详情(富文本 HTML) */
content?: string;
/** 排序 */
sort?: number;
/** 保质期 */
shelf_life?: number;
/** 库存 */
stock?: number;
/** 状态 */
status?: number;
/** 描述 */
remark?: string;
/** 分类关联数据 */
category?: IProductCategory;
/** 供应商关联数据 */
supplier?: ISupplier;
/** 多等级价格 */
prices?: IProductPrice[];
/** 创建时间 */
created_at?: string;
}
+12 -1
View File
@@ -1,3 +1,5 @@
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
/** 商品分类(多级,children 由后端组装) */
export default interface IProductCategory {
id?: number;
@@ -5,12 +7,21 @@ export default interface IProductCategory {
name?: string;
sort?: number;
status?: number;
icon?: number;
icon_id?: number;
icon?: ISysFileInfo;
icon_url?: string;
children?: IProductCategory[];
created_at?: string;
}
/** 商品分类(多级,children 由后端组装) */
export interface IProductCategoryTree {
id?: number;
parent_id?: number;
name?: string;
children?: IProductCategoryTree[];
}
export const CATEGORY_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '停用', color: 'error' },
1: { text: '正常', color: 'success' },
+1 -1
View File
@@ -38,7 +38,7 @@ const CustomerLevelPage: React.FC = () => {
},
{
title: '图片等级',
dataIndex: 'icon',
dataIndex: 'icon_id',
valueType: 'image',
fieldProps: {
action: '/customer/level/upload',
+12 -7
View File
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import React, {useEffect, useState} from 'react';
import {Button, Image, Tag, Typography} from 'antd';
import {NodeExpandOutlined} from '@ant-design/icons';
import XinTable from '@/components/XinTable';
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type { IProductCategoryTree } from '@/domain/iProductCategory.ts';
import { CATEGORY_STATUS_MAP } from '@/domain/iProductCategory.ts';
import { getCategoryTable } from '@/api/product/category.ts';
import {getCategoryTable, getCategoryTree} from '@/api/product/category.ts';
const { Title, Text } = Typography;
@@ -34,7 +35,11 @@ function collectAllIds(nodes: IProductCategory[]): number[] {
const ProductCategoryPage: React.FC = () => {
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
const [allIds, setAllIds] = useState<number[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
useEffect(() => {
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
}, []);
const columns: XinTableColumn<IProductCategory>[] = [
{
@@ -63,6 +68,7 @@ const ProductCategoryPage: React.FC = () => {
dataIndex: 'sort',
valueType: 'digit',
hideInSearch: true,
initialValue: 0,
fieldProps: { min: 0 },
align: 'center',
},
@@ -86,7 +92,7 @@ const ProductCategoryPage: React.FC = () => {
},
{
title: '分类图标',
dataIndex: 'icon',
dataIndex: 'icon_id',
valueType: 'image',
fieldProps: {
action: '/product/category/upload',
@@ -100,8 +106,8 @@ const ProductCategoryPage: React.FC = () => {
return (
<Image
src={url}
width={60}
height={60}
width={32}
height={32}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
);
@@ -134,7 +140,6 @@ const ProductCategoryPage: React.FC = () => {
handleRequest: async () => {
const res = await getCategoryTable();
const tree = res.data.data ?? [];
setCategoryTree(tree);
setAllIds(collectAllIds(tree));
return { data: tree, total: tree.length };
},
+78 -20
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Form, Image,
Input,
InputNumber,
message,
@@ -21,7 +21,7 @@ import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
import { getLevelOptions } from '@/api/customer/level.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
@@ -36,7 +36,7 @@ const { Title, Text } = Typography;
const ProductGoodsPage: React.FC = () => {
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
// ===== 价格矩阵抽屉 =====
const [matrixOpen, setMatrixOpen] = useState(false);
@@ -161,22 +161,61 @@ const ProductGoodsPage: React.FC = () => {
width: 70,
align: 'center',
},
{
title: '商品图片',
dataIndex: 'image_ids',
valueType: 'image',
fieldProps: {
action: '/product/goods/upload',
maxWidth: 3000,
maxHeight: 3000,
maxSize: 10,
mode: 'multiple',
maxCount: 5,
changeType: "id"
},
render: (_, record) => {
if (!record.images_arr || record.images_arr?.length <= 0) return '-';
return (
<Space wrap={false}>
{record.images_arr.map(i => (
<Image
key={i.id}
src={i.preview_url}
width={60}
height={60}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
);
},
align: 'center',
hideInSearch: true,
colProps: { span: 24 },
},
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
fieldProps: {
styles: { input: { height: 52, fontSize: 22 } }
},
required: true,
rules: [{ required: true, message: '请输入商品名称' }],
},
{
title: '商品描述',
dataIndex: 'remark',
valueType: 'text',
hideInSearch: true,
hideInTable: true,
colProps: { span: 24 },
},
{
title: '规格/包规',
dataIndex: 'spec',
valueType: 'text',
hideInSearch: true,
align: "center",
},
{
title: '单位',
@@ -184,12 +223,14 @@ const ProductGoodsPage: React.FC = () => {
valueType: 'text',
hideInSearch: true,
initialValue: '斤',
align: "center",
},
{
title: '分类',
dataIndex: 'category_id',
valueType: 'treeSelect',
required: true,
align: "center",
rules: [{ required: true, message: '请选择分类' }],
fieldProps: {
treeData: categoryTree,
@@ -207,6 +248,7 @@ const ProductGoodsPage: React.FC = () => {
dataIndex: 'supplier_id',
valueType: 'select',
hideInSearch: true,
align: "center",
fieldProps: {
options: suppliers.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
@@ -216,17 +258,33 @@ const ProductGoodsPage: React.FC = () => {
},
render: (_, record) => record.supplier?.name ?? '-',
},
{
title: '图文详情',
dataIndex: 'content',
valueType: 'richText',
hideInSearch: true,
hideInTable: true,
colProps: { span: 24 },
fieldProps: {
height: 400,
groupId: 11,
placeholder: '输入商品图文详情,支持插入图片',
},
},
{
title: '等级价格',
dataIndex: 'prices',
hideInForm: true,
hideInSearch: true,
width: 370,
align: 'center',
render: (_, record) => (
<Space size={[0, 4]} wrap>
<Space wrap>
{record.prices?.length
? record.prices.map((p) => (
<Tag key={p.level_id} color="geekblue">
{p.level?.name ?? `等级${p.level_id}`} ¥{p.price}
<Tag key={p.id} color="geekblue">
{p.level?.name ?? `等级${p.level_id}`}
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
</Tag>
))
: '-'}
@@ -258,19 +316,12 @@ const ProductGoodsPage: React.FC = () => {
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInSearch: true,
hideInTable: true,
fieldProps: { rows: 2 },
},
{
title: '等级价格设置',
dataIndex: 'prices',
hideInTable: true,
hideInSearch: true,
colProps: { span: 24 },
fieldRender: () => (
<Form.List name="prices">
{(fields, { add, remove }) => (
@@ -281,7 +332,7 @@ const ProductGoodsPage: React.FC = () => {
{...restField}
name={[name, 'level_id']}
rules={[{ required: true, message: '请选择等级' }]}
className="!mb-0"
className="mb-0!"
>
<Select
style={{ width: 180 }}
@@ -293,7 +344,7 @@ const ProductGoodsPage: React.FC = () => {
{...restField}
name={[name, 'price']}
rules={[{ required: true, message: '请输入单价' }]}
className="!mb-0"
className="mb-0!"
>
<InputNumber
min={0}
@@ -324,6 +375,13 @@ const ProductGoodsPage: React.FC = () => {
</Form.List>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
];
const tableProps: XinTableProps<IProduct> = {
@@ -347,7 +405,7 @@ const ProductGoodsPage: React.FC = () => {
},
modalProps: {
width: 800,
styles: {container: {height: '80vh', overflowY: "auto" }}
classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" },
},
};