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",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.13",
"mysql2": "^3.11.5",
"nodemailer": "^7.0.5",
@ -1917,6 +1918,12 @@
"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": {
"version": "1.11.18",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz",

View File

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

View File

@ -33,6 +33,7 @@ import { Subscription } from '../entity/subscription.entity';
import { Site } from '../entity/site.entity';
import { Dict } from '../entity/dict.entity';
import { DictItem } from '../entity/dict_item.entity';
import DictSeeder from '../db/seeds/dict.seeder';
export default {
// use for cookie sign key, should change to your own and keep security
@ -77,6 +78,7 @@ export default {
],
synchronize: true,
logging: false,
seeders: [DictSeeder],
},
dataSource: {
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 { CreateDictDTO, UpdateDictDTO, CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto';
import { Validate } from '@midwayjs/validate';
import { Context } from '@midwayjs/koa';
@Controller('/api')
/**
*
* @decorator Controller
*/
@Controller('/dict')
export class DictController {
@Inject()
dictService: DictService;
// 获取字典列表
@Get('/dicts')
async getDicts(@Query('title') title?: string) {
return this.dictService.getDicts(title);
@Inject()
ctx: Context;
/**
*
* @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()
async createDict(@Body() createDictDTO: CreateDictDTO) {
// 调用服务层方法
return this.dictService.createDict(createDictDTO);
}
// 更新字典
@Put('/dicts/:id')
/**
*
* @param id ID
* @param updateDictDTO
*/
@Put('/:id')
@Validate()
async updateDict(@Param('id') id: number, @Body() updateDictDTO: UpdateDictDTO) {
// 调用服务层方法
return this.dictService.updateDict(id, updateDictDTO);
}
// 删除字典
@Del('/dicts/:id')
/**
*
* @param id ID
*/
@Del('/:id')
async deleteDict(@Param('id') id: number) {
// 调用服务层方法
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) {
// 调用服务层方法
return this.dictService.getDictItems(dictId);
}
// 创建新字典项
@Post('/dict-items')
/**
*
* @param createDictItemDTO
*/
@Post('/item')
@Validate()
async createDictItem(@Body() createDictItemDTO: CreateDictItemDTO) {
// 调用服务层方法
return this.dictService.createDictItem(createDictItemDTO);
}
// 更新字典项
@Put('/dict-items/:id')
/**
*
* @param id ID
* @param updateDictItemDTO
*/
@Put('/item/:id')
@Validate()
async updateDictItem(@Param('id') id: number, @Body() updateDictItemDTO: UpdateDictItemDTO) {
// 调用服务层方法
return this.dictService.updateDictItem(id, updateDictItemDTO);
}
// 删除字典项
@Del('/dict-items/:id')
/**
*
* @param id ID
*/
@Del('/item/:id')
async deleteDictItem(@Param('id') id: number) {
// 调用服务层方法
return this.dictService.deleteDictItem(id);
}
}

View File

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

View File

@ -84,4 +84,5 @@ const options: DataSourceOptions & SeederOptions = {
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' },
];
// 在插入新数据前,清空旧数据
await dictItemRepository.query('DELETE FROM `dict_item`');
await dictRepository.query('DELETE FROM `dict`');
// 重置自增 ID
await dictItemRepository.query('ALTER TABLE `dict_item` AUTO_INCREMENT = 1');
await dictRepository.query('ALTER TABLE `dict` AUTO_INCREMENT = 1');
// 在插入新数据前,不清空旧数据,改为如果不存在则创建
// await dictItemRepository.query('DELETE FROM `dict_item`');
// await dictRepository.query('DELETE FROM `dict`');
// // 重置自增 ID
// await dictItemRepository.query('ALTER TABLE `dict_item` 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 flavorDict = await dictRepository.save({ title: '口味', name: 'flavor' });

View File

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

View File

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

View File

@ -4,8 +4,11 @@ import {
CreateDateColumn,
UpdateDateColumn,
Entity,
ManyToMany,
JoinTable,
} from 'typeorm';
import { ApiProperty } from '@midwayjs/swagger';
import { DictItem } from './dict_item.entity';
@Entity()
export class Product {
@ -31,30 +34,19 @@ export class Product {
@Column({ default: '' })
nameCn: string;
@ApiProperty({ example: '产品描述', description: '产品描述', type: 'string' })
@ApiProperty({ example: '产品描述', description: '产品描述' })
@Column({ nullable: true })
description?: string;
@ApiProperty({ example: '1', description: '品牌 ID', type: 'number' })
@Column()
brandId: number;
@ApiProperty({ description: 'sku'})
@Column({ unique: true })
sku: string;
@ApiProperty({ description: '口味ID' })
@Column()
flavorsId: number;
@ApiProperty({ description: '尼古丁强度ID' })
@Column()
strengthId: number;
@ApiProperty({ description: '湿度' })
@Column()
humidity: string;
@ApiProperty({ description: 'sku', type: 'string' })
@Column({ nullable: true })
sku?: string;
@ManyToMany(() => DictItem, {
cascade: true,
})
@JoinTable()
attributes: DictItem[];
@ApiProperty({
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 { CreateDictDTO, UpdateDictDTO } from '../dto/dict.dto';
import { CreateDictItemDTO, UpdateDictItemDTO } from '../dto/dict.dto';
import * as xlsx from 'xlsx';
@Provide()
export class DictService {
@InjectEntityModel(Dict)
dictModel: Repository<Dict>;
@InjectEntityModel(Dict)
dictModel: Repository<Dict>;
@InjectEntityModel(DictItem)
dictItemModel: Repository<DictItem>;
@InjectEntityModel(DictItem)
dictItemModel: Repository<DictItem>;
// 获取字典列表,支持按标题搜索
async getDicts(title?: string) {
// 如果提供了标题,则使用模糊查询
if (title) {
return this.dictModel.find({ where: { title: Like(`%${title}%`) } });
// 生成并返回字典的XLSX模板
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();
}
// 创建新字典
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 } } });
// 从XLSX文件导入字典
async importDictsFromXLSX(buffer: Buffer) {
// 读取缓冲区中的工作簿
const wb = xlsx.read(buffer, { type: 'buffer' });
// 获取第一个工作表的名称
const wsname = wb.SheetNames[0];
// 获取第一个工作表
const ws = wb.Sheets[wsname];
// 将工作表转换为JSON对象数组
const data = xlsx.utils.sheet_to_json(ws, { header: ['name', 'title'] }).slice(1);
// 创建要保存的字典实体数组
const dicts = data.map((row: any) => {
const dict = new Dict();
dict.name = row.name;
dict.title = row.title;
return dict;
});
// 保存字典实体数组到数据库
await this.dictModel.save(dicts);
// 返回成功导入的记录数
return { success: true, count: dicts.length };
}
// 否则,返回所有字典项
return this.dictItemModel.find();
}
// 创建新字典项
async createDictItem(createDictItemDTO: CreateDictItemDTO) {
const dict = await this.dictModel.findOneBy({ id: createDictItemDTO.dictId });
if (!dict) {
throw new Error('指定的字典不存在');
// 生成并返回字典项的XLSX模板
getDictItemXLSXTemplate() {
const headers = ['name', 'title', 'value', 'sort'];
const ws = xlsx.utils.aoa_to_sheet([headers]);
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);
}
// 更新字典项
async updateDictItem(id: number, updateDictItemDTO: UpdateDictItemDTO) {
await this.dictItemModel.update(id, updateDictItemDTO);
return this.dictItemModel.findOneBy({ id });
}
// 从XLSX文件导入字典项
async importDictItemsFromXLSX(buffer: Buffer, dictId: number) {
const dict = await this.dictModel.findOneBy({ id: dictId });
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);
// 删除字典项
async deleteDictItem(id: number) {
const result = await this.dictItemModel.delete(id);
return result.affected > 0;
}
const items = data.map((row: any) => {
const item = new DictItem();
item.name = row.name;
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,
brandId?: number
): 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
.createQueryBuilder('product')
.leftJoin(
DictItem,
'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',
]);
.leftJoinAndSelect('product.attributes', 'attribute')
.leftJoinAndSelect('attribute.dict', 'dict');
// 模糊搜索 name支持多个关键词
nameFilter.forEach((word, index) => {
qb.andWhere(`product.name LIKE :name${index}`, {
[`name${index}`]: `%${word}%`,
});
});
const nameFilter = name ? name.split(' ').filter(Boolean) : [];
if (nameFilter.length > 0) {
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) {
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
);
// 执行查询
const items = await qb.getRawMany();
const total = await qb.getCount();
const [items, total] = await qb.getManyAndCount();
// 格式化返回的数据
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 {
items,
items: formattedItems,
total,
...pagination,
};
@ -237,29 +228,31 @@ export class ProductService {
strength.title,
strength.name
);
const humidityItem = await this.getOrCreateDictItem('humidity', humidity);
// 检查产品是否已存在
const isExit = await this.productModel.findOne({
where: {
brandId: brandItem.id,
flavorsId: flavorItem.id,
strengthId: strengthItem.id,
humidity,
},
// 检查具有完全相同属性组合的产品是否已存在
const attributesToMatch = [brandItem, flavorItem, strengthItem, humidityItem];
const qb = this.productModel.createQueryBuilder('product');
attributesToMatch.forEach((attr, index) => {
qb.innerJoin(
'product.attributes',
`attr${index}`,
`attr${index}.id = :attrId${index}`,
{ [`attrId${index}`]: attr.id }
);
});
const isExit = await qb.getOne();
if (isExit) throw new Error('产品已存在');
// 创建新产品实例
const product = new Product();
product.name = name;
product.description = description;
product.brandId = brandItem.id;
product.flavorsId = flavorItem.id;
product.strengthId = strengthItem.id;
product.humidity = humidity;
product.attributes = attributesToMatch;
// 生成 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);
@ -328,34 +321,32 @@ export class ProductService {
return result.affected > 0; // `affected` 表示删除的行数
}
async hasProductsInBrand(brandId: number): Promise<boolean> {
// 检查是否有产品属于该品牌
const count = await this.productModel.count({
where: { brandId },
async hasAttribute(
dictName: string,
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;
}
async hasBrand(title: string, id?: number): Promise<boolean> {
// 查找 'brand' 字典
const brandDict = await this.dictModel.findOne({
where: { name: 'brand' },
});
// 如果字典不存在,则品牌不存在
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,
});
async hasProductsInAttribute(attributeId: number): Promise<boolean> {
const count = await this.productModel
.createQueryBuilder('product')
.innerJoin('product.attributes', 'attribute')
.where('attribute.id = :attributeId', { attributeId })
.getCount();
return count > 0;
}
@ -451,27 +442,9 @@ export class ProductService {
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(
pagination: PaginationParams,
title?: string
@ -535,12 +508,7 @@ export class ProductService {
const result = await this.dictItemModel.delete(id);
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> {
const strengthDict = await this.dictModel.findOne({