feat(字典): 重构字典模块并实现产品属性关联

重构字典模块,支持字典项与产品的多对多关联
添加字典项导入导出功能,支持XLSX模板下载
优化产品管理,使用字典项作为产品属性
新增字典项排序和值字段
修改数据源配置,添加字典种子数据
This commit is contained in:
tikkhun 2025-11-27 18:45:30 +08:00
parent 889f00bde8
commit 0809840507
13 changed files with 492 additions and 240 deletions

7
package-lock.json generated
View File

@ -25,6 +25,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mysql2": "^3.11.5", "mysql2": "^3.11.5",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",
@ -1917,6 +1918,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/csv-parse": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
"license": "MIT"
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.18", "version": "1.11.18",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz",

View File

@ -20,6 +20,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mysql2": "^3.11.5", "mysql2": "^3.11.5",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",

View File

@ -33,6 +33,7 @@ import { Subscription } from '../entity/subscription.entity';
import { Site } from '../entity/site.entity'; import { Site } from '../entity/site.entity';
import { Dict } from '../entity/dict.entity'; import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity'; import { DictItem } from '../entity/dict_item.entity';
import DictSeeder from '../db/seeds/dict.seeder';
export default { export default {
// use for cookie sign key, should change to your own and keep security // use for cookie sign key, should change to your own and keep security
@ -77,6 +78,7 @@ export default {
], ],
synchronize: true, synchronize: true,
logging: false, logging: false,
seeders: [DictSeeder],
}, },
dataSource: { dataSource: {
default: { default: {

View File

@ -1,62 +1,171 @@
import { Inject, Controller, Get, Post, Put, Del, Query, Body, Param } from '@midwayjs/core';
import { Inject, Controller, Get, Post, Put, Del, Query, Body, Param, Files, ContentType } from '@midwayjs/core';
import { DictService } from '../service/dict.service'; import { DictService } from '../service/dict.service';
import { CreateDictDTO, UpdateDictDTO, CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto'; import { CreateDictDTO, UpdateDictDTO, CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto';
import { Validate } from '@midwayjs/validate'; import { Validate } from '@midwayjs/validate';
import { Context } from '@midwayjs/koa';
@Controller('/api') /**
*
* @decorator Controller
*/
@Controller('/dict')
export class DictController { export class DictController {
@Inject() @Inject()
dictService: DictService; dictService: DictService;
// 获取字典列表 @Inject()
@Get('/dicts') ctx: Context;
async getDicts(@Query('title') title?: string) {
return this.dictService.getDicts(title); /**
*
* @param files
*/
@Post('/import')
@Validate()
async importDicts(@Files() files: any) {
// 从上传的文件列表中获取第一个文件
const file = files[0];
// 调用服务层方法处理XLSX文件
const result = await this.dictService.importDictsFromXLSX(file.data);
// 返回导入结果
return result;
} }
// 创建新字典 /**
@Post('/dicts') * XLSX模板
*/
@Get('/template')
@ContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
async downloadDictTemplate() {
// 设置下载文件的名称
this.ctx.set('Content-Disposition', 'attachment; filename=dict-template.xlsx');
// 返回XLSX模板内容
return this.dictService.getDictXLSXTemplate();
}
/**
*
* @param id ID
*/
@Get('/:id')
async getDict(@Param('id') id: number) {
// 调用服务层方法,并关联查询字典项
return this.dictService.getDict({ id }, ['items']);
}
/**
*
* @param title ()
* @param name ()
*/
@Get('/list')
async getDicts(@Query('title') title?: string, @Query('name') name?: string) {
// 调用服务层方法
return this.dictService.getDicts({ title, name });
}
/**
*
* @param createDictDTO
*/
@Post('/')
@Validate() @Validate()
async createDict(@Body() createDictDTO: CreateDictDTO) { async createDict(@Body() createDictDTO: CreateDictDTO) {
// 调用服务层方法
return this.dictService.createDict(createDictDTO); return this.dictService.createDict(createDictDTO);
} }
// 更新字典 /**
@Put('/dicts/:id') *
* @param id ID
* @param updateDictDTO
*/
@Put('/:id')
@Validate() @Validate()
async updateDict(@Param('id') id: number, @Body() updateDictDTO: UpdateDictDTO) { async updateDict(@Param('id') id: number, @Body() updateDictDTO: UpdateDictDTO) {
// 调用服务层方法
return this.dictService.updateDict(id, updateDictDTO); return this.dictService.updateDict(id, updateDictDTO);
} }
// 删除字典 /**
@Del('/dicts/:id') *
* @param id ID
*/
@Del('/:id')
async deleteDict(@Param('id') id: number) { async deleteDict(@Param('id') id: number) {
// 调用服务层方法
return this.dictService.deleteDict(id); return this.dictService.deleteDict(id);
} }
// 获取字典项列表 /**
@Get('/dict-items') *
* @param files
* @param body ID
*/
@Post('/item/import')
@Validate()
async importDictItems(@Files() files: any, @Body() body: { dictId: number }) {
// 获取第一个文件
const file = files[0];
// 调用服务层方法
const result = await this.dictService.importDictItemsFromXLSX(file.data, body.dictId);
// 返回结果
return result;
}
/**
* XLSX模板
*/
@Get('/item/template')
@ContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
async downloadDictItemTemplate() {
// 设置下载文件名
this.ctx.set('Content-Disposition', 'attachment; filename=dict-item-template.xlsx');
// 返回模板内容
return this.dictService.getDictItemXLSXTemplate();
}
/**
*
* @param dictId ID ()
*/
@Get('/items')
async getDictItems(@Query('dictId') dictId?: number) { async getDictItems(@Query('dictId') dictId?: number) {
// 调用服务层方法
return this.dictService.getDictItems(dictId); return this.dictService.getDictItems(dictId);
} }
// 创建新字典项 /**
@Post('/dict-items') *
* @param createDictItemDTO
*/
@Post('/item')
@Validate() @Validate()
async createDictItem(@Body() createDictItemDTO: CreateDictItemDTO) { async createDictItem(@Body() createDictItemDTO: CreateDictItemDTO) {
// 调用服务层方法
return this.dictService.createDictItem(createDictItemDTO); return this.dictService.createDictItem(createDictItemDTO);
} }
// 更新字典项 /**
@Put('/dict-items/:id') *
* @param id ID
* @param updateDictItemDTO
*/
@Put('/item/:id')
@Validate() @Validate()
async updateDictItem(@Param('id') id: number, @Body() updateDictItemDTO: UpdateDictItemDTO) { async updateDictItem(@Param('id') id: number, @Body() updateDictItemDTO: UpdateDictItemDTO) {
// 调用服务层方法
return this.dictService.updateDictItem(id, updateDictItemDTO); return this.dictService.updateDictItem(id, updateDictItemDTO);
} }
// 删除字典项 /**
@Del('/dict-items/:id') *
* @param id ID
*/
@Del('/item/:id')
async deleteDictItem(@Param('id') id: number) { async deleteDictItem(@Param('id') id: number) {
// 调用服务层方法
return this.dictService.deleteDictItem(id); return this.dictService.deleteDictItem(id);
} }
} }

View File

@ -185,7 +185,8 @@ export class ProductController {
@Post('/brand') @Post('/brand')
async createBrand(@Body() brandData: CreateBrandDTO) { async createBrand(@Body() brandData: CreateBrandDTO) {
try { try {
const hasBrand = await this.productService.hasBrand( const hasBrand = await this.productService.hasAttribute(
'brand',
brandData.name brandData.name
); );
if (hasBrand) { if (hasBrand) {
@ -207,8 +208,10 @@ export class ProductController {
@Body() brandData: UpdateBrandDTO @Body() brandData: UpdateBrandDTO
) { ) {
try { try {
const hasBrand = await this.productService.hasBrand( const hasBrand = await this.productService.hasAttribute(
brandData.name 'brand',
brandData.name,
id
); );
if (hasBrand) { if (hasBrand) {
return errorResponse('品牌已存在'); return errorResponse('品牌已存在');
@ -226,7 +229,7 @@ export class ProductController {
@Del('/brand/:id') @Del('/brand/:id')
async deleteBrand(@Param('id') id: number) { async deleteBrand(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInBrand(id); const hasProducts = await this.productService.hasProductsInAttribute(id);
if (hasProducts) throw new Error('该品牌下有商品,无法删除'); if (hasProducts) throw new Error('该品牌下有商品,无法删除');
const data = await this.productService.deleteBrand(id); const data = await this.productService.deleteBrand(id);
return successResponse(data); return successResponse(data);
@ -279,7 +282,7 @@ export class ProductController {
@Post('/flavors') @Post('/flavors')
async createFlavors(@Body() flavorsData: CreateFlavorsDTO) { async createFlavors(@Body() flavorsData: CreateFlavorsDTO) {
try { try {
const hasFlavors = await this.productService.hasFlavors(flavorsData.name); const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name);
if (hasFlavors) { if (hasFlavors) {
return errorResponse('口味已存在'); return errorResponse('口味已存在');
} }
@ -297,7 +300,7 @@ export class ProductController {
@Body() flavorsData: UpdateFlavorsDTO @Body() flavorsData: UpdateFlavorsDTO
) { ) {
try { try {
const hasFlavors = await this.productService.hasFlavors(flavorsData.name); const hasFlavors = await this.productService.hasAttribute('flavor', flavorsData.name, id);
if (hasFlavors) { if (hasFlavors) {
return errorResponse('口味已存在'); return errorResponse('口味已存在');
} }
@ -314,7 +317,7 @@ export class ProductController {
@Del('/flavors/:id') @Del('/flavors/:id')
async deleteFlavors(@Param('id') id: number) { async deleteFlavors(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInFlavors(id); const hasProducts = await this.productService.hasProductsInAttribute(id);
if (hasProducts) throw new Error('该口味下有商品,无法删除'); if (hasProducts) throw new Error('该口味下有商品,无法删除');
const data = await this.productService.deleteFlavors(id); const data = await this.productService.deleteFlavors(id);
return successResponse(data); return successResponse(data);
@ -353,7 +356,8 @@ export class ProductController {
@Post('/strength') @Post('/strength')
async createStrength(@Body() strengthData: CreateStrengthDTO) { async createStrength(@Body() strengthData: CreateStrengthDTO) {
try { try {
const hasStrength = await this.productService.hasStrength( const hasStrength = await this.productService.hasAttribute(
'strength',
strengthData.name strengthData.name
); );
if (hasStrength) { if (hasStrength) {
@ -373,8 +377,10 @@ export class ProductController {
@Body() strengthData: UpdateStrengthDTO @Body() strengthData: UpdateStrengthDTO
) { ) {
try { try {
const hasStrength = await this.productService.hasStrength( const hasStrength = await this.productService.hasAttribute(
strengthData.name 'strength',
strengthData.name,
id
); );
if (hasStrength) { if (hasStrength) {
return errorResponse('规格已存在'); return errorResponse('规格已存在');
@ -392,7 +398,7 @@ export class ProductController {
@Del('/strength/:id') @Del('/strength/:id')
async deleteStrength(@Param('id') id: number) { async deleteStrength(@Param('id') id: number) {
try { try {
const hasProducts = await this.productService.hasProductsInStrength(id); const hasProducts = await this.productService.hasProductsInAttribute(id);
if (hasProducts) throw new Error('该规格下有商品,无法删除'); if (hasProducts) throw new Error('该规格下有商品,无法删除');
const data = await this.productService.deleteStrength(id); const data = await this.productService.deleteStrength(id);
return successResponse(data); return successResponse(data);

View File

@ -84,4 +84,5 @@ const options: DataSourceOptions & SeederOptions = {
seeds: ['src/db/seeds/**/*.ts'], seeds: ['src/db/seeds/**/*.ts'],
}; };
export default new DataSource(options); export const AppDataSource = new DataSource(options);

View File

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ProductDictItemManyToMany1764238434984 implements MigrationInterface {
name = 'ProductDictItemManyToMany1764238434984'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`product_attributes_dict_item\` (\`productId\` int NOT NULL, \`dictItemId\` int NOT NULL, INDEX \`IDX_592cdbdaebfec346c202ffb82c\` (\`productId\`), INDEX \`IDX_406c1da5b6de45fecb7967c3ec\` (\`dictItemId\`), PRIMARY KEY (\`productId\`, \`dictItemId\`)) ENGINE=InnoDB`);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`brandId\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`flavorsId\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`strengthId\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`humidity\``);
await queryRunner.query(`ALTER TABLE \`product\` ADD \`sku\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`product\` ADD UNIQUE INDEX \`IDX_34f6ca1cd897cc926bdcca1ca3\` (\`sku\`)`);
await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` ADD CONSTRAINT \`FK_592cdbdaebfec346c202ffb82ca\` FOREIGN KEY (\`productId\`) REFERENCES \`product\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` ADD CONSTRAINT \`FK_406c1da5b6de45fecb7967c3ec0\` FOREIGN KEY (\`dictItemId\`) REFERENCES \`dict_item\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` DROP FOREIGN KEY \`FK_406c1da5b6de45fecb7967c3ec0\``);
await queryRunner.query(`ALTER TABLE \`product_attributes_dict_item\` DROP FOREIGN KEY \`FK_592cdbdaebfec346c202ffb82ca\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP INDEX \`IDX_34f6ca1cd897cc926bdcca1ca3\``);
await queryRunner.query(`ALTER TABLE \`product\` DROP COLUMN \`sku\``);
await queryRunner.query(`ALTER TABLE \`product\` ADD \`humidity\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`product\` ADD \`strengthId\` int NOT NULL`);
await queryRunner.query(`ALTER TABLE \`product\` ADD \`flavorsId\` int NOT NULL`);
await queryRunner.query(`ALTER TABLE \`product\` ADD \`brandId\` int NOT NULL`);
await queryRunner.query(`DROP INDEX \`IDX_406c1da5b6de45fecb7967c3ec\` ON \`product_attributes_dict_item\``);
await queryRunner.query(`DROP INDEX \`IDX_592cdbdaebfec346c202ffb82c\` ON \`product_attributes_dict_item\``);
await queryRunner.query(`DROP TABLE \`product_attributes_dict_item\``);
}
}

View File

@ -69,12 +69,52 @@ export default class DictSeeder implements Seeder {
{ id: 10, title: '30MG', name: '30MG' }, { id: 10, title: '30MG', name: '30MG' },
]; ];
// 在插入新数据前,清空旧数据 // 在插入新数据前,不清空旧数据,改为如果不存在则创建
await dictItemRepository.query('DELETE FROM `dict_item`'); // await dictItemRepository.query('DELETE FROM `dict_item`');
await dictRepository.query('DELETE FROM `dict`'); // await dictRepository.query('DELETE FROM `dict`');
// 重置自增 ID // // 重置自增 ID
await dictItemRepository.query('ALTER TABLE `dict_item` AUTO_INCREMENT = 1'); // await dictItemRepository.query('ALTER TABLE `dict_item` AUTO_INCREMENT = 1');
await dictRepository.query('ALTER TABLE `dict` AUTO_INCREMENT = 1'); // await dictRepository.query('ALTER TABLE `dict` AUTO_INCREMENT = 1');
// 初始化语言字典
const locales = [
{ name: 'zh-CN', title: '简体中文' },
{ name: 'en-US', title: 'English' },
];
for (const locale of locales) {
let dict = await dictRepository.findOne({ where: { name: locale.name } });
if (!dict) {
dict = await dictRepository.save(locale);
}
}
// 添加示例翻译条目
const zhDict = await dictRepository.findOne({ where: { name: 'zh-CN' } });
const enDict = await dictRepository.findOne({ where: { name: 'en-US' } });
const translations = [
{ name: 'common.save', zh: '保存', en: 'Save' },
{ name: 'common.cancel', zh: '取消', en: 'Cancel' },
{ name: 'common.success', zh: '操作成功', en: 'Success' },
{ name: 'common.failure', zh: '操作失败', en: 'Failure' },
];
for (const t of translations) {
// 添加中文翻译
let item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: zhDict.id } } });
if (!item) {
await dictItemRepository.save({ name: t.name, title: t.zh, dict: zhDict });
}
// 添加英文翻译
item = await dictItemRepository.findOne({ where: { name: t.name, dict: { id: enDict.id } } });
if (!item) {
await dictItemRepository.save({ name: t.name, title: t.en, dict: enDict });
}
}
const brandDict = await dictRepository.save({ title: '品牌', name: 'brand' }); const brandDict = await dictRepository.save({ title: '品牌', name: 'brand' });
const flavorDict = await dictRepository.save({ title: '口味', name: 'flavor' }); const flavorDict = await dictRepository.save({ title: '口味', name: 'flavor' });

View File

@ -29,6 +29,9 @@ export class Dict {
@OneToMany(() => DictItem, item => item.dict) @OneToMany(() => DictItem, item => item.dict)
items: DictItem[]; items: DictItem[];
// 是否可删除
@Column({ default: true, comment: '是否可删除' })
deletable: boolean;
// 创建时间 // 创建时间
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@ -4,11 +4,13 @@
* @date 2025-11-27 * @date 2025-11-27
*/ */
import { Dict } from './dict.entity'; import { Dict } from './dict.entity';
import { Product } from './product.entity';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn, JoinColumn,
ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
@ -28,11 +30,23 @@ export class DictItem {
@Column({ unique: true, comment: '字典唯一标识名称' }) @Column({ unique: true, comment: '字典唯一标识名称' })
name: string; name: string;
// 字典项值
@Column({ nullable: true, comment: '字典项值' })
value?: string;
// 排序
@Column({ default: 0, comment: '排序' })
sort: number;
// 属于哪个字典 // 属于哪个字典
@ManyToOne(() => Dict, dict => dict.items) @ManyToOne(() => Dict, dict => dict.items)
@JoinColumn({ name: 'dict_id' }) @JoinColumn({ name: 'dict_id' })
dict: Dict; dict: Dict;
// 关联的产品
@ManyToMany(() => Product, product => product.attributes)
products: Product[];
// 创建时间 // 创建时间
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@ -4,8 +4,11 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
Entity, Entity,
ManyToMany,
JoinTable,
} from 'typeorm'; } from 'typeorm';
import { ApiProperty } from '@midwayjs/swagger'; import { ApiProperty } from '@midwayjs/swagger';
import { DictItem } from './dict_item.entity';
@Entity() @Entity()
export class Product { export class Product {
@ -31,30 +34,19 @@ export class Product {
@Column({ default: '' }) @Column({ default: '' })
nameCn: string; nameCn: string;
@ApiProperty({ example: '产品描述', description: '产品描述', type: 'string' }) @ApiProperty({ example: '产品描述', description: '产品描述' })
@Column({ nullable: true }) @Column({ nullable: true })
description?: string; description?: string;
@ApiProperty({ example: '1', description: '品牌 ID', type: 'number' }) @ApiProperty({ description: 'sku'})
@Column() @Column({ unique: true })
brandId: number; sku: string;
@ApiProperty({ description: '口味ID' }) @ManyToMany(() => DictItem, {
@Column() cascade: true,
flavorsId: number; })
@JoinTable()
@ApiProperty({ description: '尼古丁强度ID' }) attributes: DictItem[];
@Column()
strengthId: number;
@ApiProperty({ description: '湿度' })
@Column()
humidity: string;
@ApiProperty({ description: 'sku', type: 'string' })
@Column({ nullable: true })
sku?: string;
@ApiProperty({ @ApiProperty({
example: '2022-12-12 11:11:11', example: '2022-12-12 11:11:11',

View File

@ -5,80 +5,157 @@ import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity'; import { DictItem } from '../entity/dict_item.entity';
import { CreateDictDTO, UpdateDictDTO } from '../dto/dict.dto'; import { CreateDictDTO, UpdateDictDTO } from '../dto/dict.dto';
import { CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto'; import { CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto';
import * as xlsx from 'xlsx';
@Provide() @Provide()
export class DictService { export class DictService {
@InjectEntityModel(Dict)
dictModel: Repository<Dict>;
@InjectEntityModel(DictItem) @InjectEntityModel(Dict)
dictItemModel: Repository<DictItem>; dictModel: Repository<Dict>;
// 获取字典列表,支持按标题搜索 @InjectEntityModel(DictItem)
async getDicts(title?: string) { dictItemModel: Repository<DictItem>;
// 如果提供了标题,则使用模糊查询
if (title) { // 生成并返回字典的XLSX模板
return this.dictModel.find({ where: { title: Like(`%${title}%`) } }); getDictXLSXTemplate() {
// 定义表头
const headers = ['name', 'title'];
// 创建一个新的工作表
const ws = xlsx.utils.aoa_to_sheet([headers]);
// 创建一个新的工作簿
const wb = xlsx.utils.book_new();
// 将工作表添加到工作簿
xlsx.utils.book_append_sheet(wb, ws, 'Dicts');
// 将工作簿写入缓冲区
return xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
} }
// 否则,返回所有字典
return this.dictModel.find();
}
// 创建新字典 // 从XLSX文件导入字典
async createDict(createDictDTO: CreateDictDTO) { async importDictsFromXLSX(buffer: Buffer) {
const dict = new Dict(); // 读取缓冲区中的工作簿
dict.name = createDictDTO.name; const wb = xlsx.read(buffer, { type: 'buffer' });
dict.title = createDictDTO.title; // 获取第一个工作表的名称
return this.dictModel.save(dict); const wsname = wb.SheetNames[0];
} // 获取第一个工作表
const ws = wb.Sheets[wsname];
// 更新字典 // 将工作表转换为JSON对象数组
async updateDict(id: number, updateDictDTO: UpdateDictDTO) { const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title'] }).slice(1);
await this.dictModel.update(id, updateDictDTO); // 创建要保存的字典实体数组
return this.dictModel.findOneBy({ id }); const dicts = data.map((row: any) => {
} const dict = new Dict();
dict.name = row.name;
// 删除字典及其所有字典项 dict.title = row.title;
async deleteDict(id: number) { return dict;
// 首先删除该字典下的所有字典项 });
await this.dictItemModel.delete({ dict: { id } }); // 保存字典实体数组到数据库
// 然后删除字典本身 await this.dictModel.save(dicts);
const result = await this.dictModel.delete(id); // 返回成功导入的记录数
return result.affected > 0; return { success: true, count: dicts.length };
}
// 获取字典项列表,支持按 dictId 过滤
async getDictItems(dictId?: number) {
// 如果提供了 dictId则只返回该字典下的项
if (dictId) {
return this.dictItemModel.find({ where: { dict: { id: dictId } } });
} }
// 否则,返回所有字典项
return this.dictItemModel.find();
}
// 创建新字典项 // 生成并返回字典项的XLSX模板
async createDictItem(createDictItemDTO: CreateDictItemDTO) { getDictItemXLSXTemplate() {
const dict = await this.dictModel.findOneBy({ id: createDictItemDTO.dictId }); const headers = ['name', 'title', 'value', 'sort'];
if (!dict) { const ws = xlsx.utils.aoa_to_sheet([headers]);
throw new Error('指定的字典不存在'); const wb = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(wb, ws, 'DictItems');
return xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
} }
const item = new DictItem();
item.name = createDictItemDTO.name;
item.title = createDictItemDTO.title;
item.dict = dict;
return this.dictItemModel.save(item);
}
// 更新字典项 // 从XLSX文件导入字典项
async updateDictItem(id: number, updateDictItemDTO: UpdateDictItemDTO) { async importDictItemsFromXLSX(buffer: Buffer, dictId: number) {
await this.dictItemModel.update(id, updateDictItemDTO); const dict = await this.dictModel.findOneBy({ id: dictId });
return this.dictItemModel.findOneBy({ id }); if (!dict) {
} throw new Error('指定的字典不存在');
}
const wb = xlsx.read(buffer, { type: 'buffer' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title', 'value', 'sort'] }).slice(1);
// 删除字典项 const items = data.map((row: any) => {
async deleteDictItem(id: number) { const item = new DictItem();
const result = await this.dictItemModel.delete(id); item.name = row.name;
return result.affected > 0; item.title = row.title;
} item.value = row.value;
item.sort = row.sort || 0;
item.dict = dict;
return item;
});
await this.dictItemModel.save(items);
return { success: true, count: items.length };
}
getDict(where: { name?: string; id?: number; }, relations: string[]) {
if (!where.name && !where.id) {
throw new Error('必须提供 name 或 id');
}
return this.dictModel.findOne({ where, relations });
}
// 获取字典列表,支持按标题搜索
async getDicts(options: { title?: string; name?: string; }) {
const where = {
title: options.title ? Like(`%${options.title}%`) : undefined,
name: options.name ? Like(`%${options.name}%`) : undefined,
}
return this.dictModel.find({ where });
}
// 创建新字典
async createDict(createDictDTO: CreateDictDTO) {
const dict = new Dict();
dict.name = createDictDTO.name;
dict.title = createDictDTO.title;
return this.dictModel.save(dict);
}
// 更新字典
async updateDict(id: number, updateDictDTO: UpdateDictDTO) {
await this.dictModel.update(id, updateDictDTO);
return this.dictModel.findOneBy({ id });
}
// 删除字典及其所有字典项
async deleteDict(id: number) {
// 首先删除该字典下的所有字典项
await this.dictItemModel.delete({ dict: { id } });
// 然后删除字典本身
const result = await this.dictModel.delete(id);
return result.affected > 0;
}
// 获取字典项列表,支持按 dictId 过滤
async getDictItems(dictId?: number) {
// 如果提供了 dictId则只返回该字典下的项
if (dictId) {
return this.dictItemModel.find({ where: { dict: { id: dictId } } });
}
// 否则,返回所有字典项
return this.dictItemModel.find();
}
// 创建新字典项
async createDictItem(createDictItemDTO: CreateDictItemDTO) {
const dict = await this.dictModel.findOneBy({ id: createDictItemDTO.dictId });
if (!dict) {
throw new Error('指定的字典不存在');
}
const item = new DictItem();
item.name = createDictItemDTO.name;
item.title = createDictItemDTO.title;
item.dict = dict;
return this.dictItemModel.save(item);
}
// 更新字典项
async updateDictItem(id: number, updateDictItemDTO: UpdateDictItemDTO) {
await this.dictItemModel.update(id, updateDictItemDTO);
return this.dictItemModel.findOneBy({ id });
}
// 删除字典项
async deleteDictItem(id: number) {
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
} }

View File

@ -113,64 +113,37 @@ export class ProductService {
name?: string, name?: string,
brandId?: number brandId?: number
): Promise<ProductPaginatedResponse> { ): Promise<ProductPaginatedResponse> {
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
// 查询品牌、口味、规格字典
const brandDict = await this.dictModel.findOne({
where: { name: 'brand' },
});
const flavorDict = await this.dictModel.findOne({
where: { name: 'flavor' },
});
const strengthDict = await this.dictModel.findOne({
where: { name: 'strength' },
});
// 构建查询
const qb = this.productModel const qb = this.productModel
.createQueryBuilder('product') .createQueryBuilder('product')
.leftJoin( .leftJoinAndSelect('product.attributes', 'attribute')
DictItem, .leftJoinAndSelect('attribute.dict', 'dict');
'brand',
'brand.id = product.brandId AND brand.dict = :brandDictId',
{ brandDictId: brandDict?.id }
)
.leftJoin(
DictItem,
'flavor',
'flavor.id = product.flavorsId AND flavor.dict = :flavorDictId',
{ flavorDictId: flavorDict?.id }
)
.leftJoin(
DictItem,
'strength',
'strength.id = product.strengthId AND strength.dict = :strengthDictId',
{ strengthDictId: strengthDict?.id }
)
.select([
'product.id as id',
'product.name as name',
'product.nameCn as nameCn',
'product.description as description',
'product.humidity as humidity',
'product.sku as sku',
'product.createdAt as createdAt',
'product.updatedAt as updatedAt',
'brand.title AS brandName',
'flavor.title AS flavorsName',
'strength.title AS strengthName',
]);
// 模糊搜索 name支持多个关键词 // 模糊搜索 name支持多个关键词
nameFilter.forEach((word, index) => { const nameFilter = name ? name.split(' ').filter(Boolean) : [];
qb.andWhere(`product.name LIKE :name${index}`, { if (nameFilter.length > 0) {
[`name${index}`]: `%${word}%`, const nameConditions = nameFilter
}); .map((word, index) => `product.name LIKE :name${index}`)
}); .join(' AND ');
const nameParams = nameFilter.reduce(
(params, word, index) => ({ ...params, [`name${index}`]: `%${word}%` }),
{}
);
qb.where(`(${nameConditions})`, nameParams);
}
// 品牌过滤 // 品牌过滤
if (brandId) { if (brandId) {
qb.andWhere('product.brandId = :brandId', { brandId }); qb.andWhere(qb => {
const subQuery = qb
.subQuery()
.select('product_attributes_dict_item.productId')
.from('product_attributes_dict_item', 'product_attributes_dict_item')
.where('product_attributes_dict_item.dictItemId = :brandId', {
brandId,
})
.getQuery();
return 'product.id IN ' + subQuery;
});
} }
// 分页 // 分页
@ -178,12 +151,30 @@ export class ProductService {
pagination.pageSize pagination.pageSize
); );
// 执行查询 const [items, total] = await qb.getManyAndCount();
const items = await qb.getRawMany();
const total = await qb.getCount(); // 格式化返回的数据
const formattedItems = items.map(product => {
const getAttributeTitle = (dictName: string) =>
product.attributes.find(a => a.dict.name === dictName)?.title || null;
return {
id: product.id,
name: product.name,
nameCn: product.nameCn,
description: product.description,
humidity: getAttributeTitle('humidity'),
sku: product.sku,
createdAt: product.createdAt,
updatedAt: product.updatedAt,
brandName: getAttributeTitle('brand'),
flavorsName: getAttributeTitle('flavor'),
strengthName: getAttributeTitle('strength'),
};
});
return { return {
items, items: formattedItems,
total, total,
...pagination, ...pagination,
}; };
@ -237,29 +228,31 @@ export class ProductService {
strength.title, strength.title,
strength.name strength.name
); );
const humidityItem = await this.getOrCreateDictItem('humidity', humidity);
// 检查产品是否已存在 // 检查具有完全相同属性组合的产品是否已存在
const isExit = await this.productModel.findOne({ const attributesToMatch = [brandItem, flavorItem, strengthItem, humidityItem];
where: { const qb = this.productModel.createQueryBuilder('product');
brandId: brandItem.id, attributesToMatch.forEach((attr, index) => {
flavorsId: flavorItem.id, qb.innerJoin(
strengthId: strengthItem.id, 'product.attributes',
humidity, `attr${index}`,
}, `attr${index}.id = :attrId${index}`,
{ [`attrId${index}`]: attr.id }
);
}); });
const isExit = await qb.getOne();
if (isExit) throw new Error('产品已存在'); if (isExit) throw new Error('产品已存在');
// 创建新产品实例 // 创建新产品实例
const product = new Product(); const product = new Product();
product.name = name; product.name = name;
product.description = description; product.description = description;
product.brandId = brandItem.id; product.attributes = attributesToMatch;
product.flavorsId = flavorItem.id;
product.strengthId = strengthItem.id;
product.humidity = humidity;
// 生成 SKU // 生成 SKU
product.sku = `${brandItem.name}-${flavorItem.name}-${strengthItem.name}-${humidity}`; product.sku = `${brandItem.name}-${flavorItem.name}-${strengthItem.name}-${humidityItem.name}`;
// 保存产品 // 保存产品
return await this.productModel.save(product); return await this.productModel.save(product);
@ -328,34 +321,32 @@ export class ProductService {
return result.affected > 0; // `affected` 表示删除的行数 return result.affected > 0; // `affected` 表示删除的行数
} }
async hasProductsInBrand(brandId: number): Promise<boolean> {
// 检查是否有产品属于该品牌 async hasAttribute(
const count = await this.productModel.count({ dictName: string,
where: { brandId }, title: string,
id?: number
): Promise<boolean> {
const dict = await this.dictModel.findOne({
where: { name: dictName },
});
if (!dict) {
return false;
}
const where: any = { title, dict: { id: dict.id } };
if (id) where.id = Not(id);
const count = await this.dictItemModel.count({
where,
}); });
return count > 0; return count > 0;
} }
async hasBrand(title: string, id?: number): Promise<boolean> { async hasProductsInAttribute(attributeId: number): Promise<boolean> {
// 查找 'brand' 字典 const count = await this.productModel
const brandDict = await this.dictModel.findOne({ .createQueryBuilder('product')
where: { name: 'brand' }, .innerJoin('product.attributes', 'attribute')
}); .where('attribute.id = :attributeId', { attributeId })
.getCount();
// 如果字典不存在,则品牌不存在
if (!brandDict) {
return false;
}
// 设置查询条件
const where: any = { title, dict: { id: brandDict.id } };
if (id) where.id = Not(id);
// 统计数量
const count = await this.dictItemModel.count({
where,
});
return count > 0; return count > 0;
} }
@ -451,27 +442,9 @@ export class ProductService {
return result.affected > 0; // `affected` 表示删除的行数 return result.affected > 0; // `affected` 表示删除的行数
} }
async hasProductsInFlavors(flavorsId: number): Promise<boolean> {
const count = await this.productModel.count({
where: { flavorsId },
});
return count > 0;
}
async hasFlavors(title: string, id?: string): Promise<boolean> {
const flavorsDict = await this.dictModel.findOne({
where: { name: 'flavor' },
});
if (!flavorsDict) {
return false;
}
const where: any = { title, dict_id: flavorsDict.id };
if (id) where.id = Not(id);
const count = await this.dictItemModel.count({
where,
});
return count > 0;
}
async getFlavorsList( async getFlavorsList(
pagination: PaginationParams, pagination: PaginationParams,
title?: string title?: string
@ -535,12 +508,7 @@ export class ProductService {
const result = await this.dictItemModel.delete(id); const result = await this.dictItemModel.delete(id);
return result.affected > 0; return result.affected > 0;
} }
async hasProductsInStrength(strengthId: number): Promise<boolean> {
const count = await this.productModel.count({
where: { strengthId },
});
return count > 0;
}
async hasStrength(title: string, id?: string): Promise<boolean> { async hasStrength(title: string, id?: string): Promise<boolean> {
const strengthDict = await this.dictModel.findOne({ const strengthDict = await this.dictModel.findOne({