From 0809840507f2a65894ada7c2ef2c59e0a7a5b630 Mon Sep 17 00:00:00 2001 From: tikkhun Date: Thu, 27 Nov 2025 18:45:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AD=97=E5=85=B8):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E6=A8=A1=E5=9D=97=E5=B9=B6=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E5=B1=9E=E6=80=A7=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构字典模块,支持字典项与产品的多对多关联 添加字典项导入导出功能,支持XLSX模板下载 优化产品管理,使用字典项作为产品属性 新增字典项排序和值字段 修改数据源配置,添加字典种子数据 --- package-lock.json | 7 + package.json | 1 + src/config/config.default.ts | 2 + src/controller/dict.controller.ts | 149 +++++++++++-- src/controller/product.controller.ts | 28 ++- src/db/datasource.ts | 3 +- ...38434984-product-dict-item-many-to-many.ts | 32 +++ src/db/seeds/dict.seeder.ts | 52 ++++- src/entity/dict.entity.ts | 3 + src/entity/dict_item.entity.ts | 14 ++ src/entity/product.entity.ts | 32 +-- src/service/dict.service.ts | 205 ++++++++++++------ src/service/product.service.ts | 204 ++++++++--------- 13 files changed, 492 insertions(+), 240 deletions(-) create mode 100644 src/db/migrations/1764238434984-product-dict-item-many-to-many.ts diff --git a/package-lock.json b/package-lock.json index d9e1020..371e5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c675c7e..169ab4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config/config.default.ts b/src/config/config.default.ts index b384d91..2976b37 100644 --- a/src/config/config.default.ts +++ b/src/config/config.default.ts @@ -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: { diff --git a/src/controller/dict.controller.ts b/src/controller/dict.controller.ts index a8e082a..d80b89f 100644 --- a/src/controller/dict.controller.ts +++ b/src/controller/dict.controller.ts @@ -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); } } diff --git a/src/controller/product.controller.ts b/src/controller/product.controller.ts index 4d7f7fd..1313485 100644 --- a/src/controller/product.controller.ts +++ b/src/controller/product.controller.ts @@ -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); diff --git a/src/db/datasource.ts b/src/db/datasource.ts index 1f3b84f..47f57b7 100644 --- a/src/db/datasource.ts +++ b/src/db/datasource.ts @@ -84,4 +84,5 @@ const options: DataSourceOptions & SeederOptions = { seeds: ['src/db/seeds/**/*.ts'], }; -export default new DataSource(options); +export const AppDataSource = new DataSource(options); + diff --git a/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts b/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts new file mode 100644 index 0000000..1011b18 --- /dev/null +++ b/src/db/migrations/1764238434984-product-dict-item-many-to-many.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ProductDictItemManyToMany1764238434984 implements MigrationInterface { + name = 'ProductDictItemManyToMany1764238434984' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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\``); + } + +} diff --git a/src/db/seeds/dict.seeder.ts b/src/db/seeds/dict.seeder.ts index 2201f54..5a9d0ed 100644 --- a/src/db/seeds/dict.seeder.ts +++ b/src/db/seeds/dict.seeder.ts @@ -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' }); diff --git a/src/entity/dict.entity.ts b/src/entity/dict.entity.ts index 3a63ce1..dc0d9af 100644 --- a/src/entity/dict.entity.ts +++ b/src/entity/dict.entity.ts @@ -29,6 +29,9 @@ export class Dict { @OneToMany(() => DictItem, item => item.dict) items: DictItem[]; + // 是否可删除 + @Column({ default: true, comment: '是否可删除' }) + deletable: boolean; // 创建时间 @CreateDateColumn() createdAt: Date; diff --git a/src/entity/dict_item.entity.ts b/src/entity/dict_item.entity.ts index 4a4ac1a..61fe9b2 100644 --- a/src/entity/dict_item.entity.ts +++ b/src/entity/dict_item.entity.ts @@ -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; diff --git a/src/entity/product.entity.ts b/src/entity/product.entity.ts index dc72c9e..8ef7208 100644 --- a/src/entity/product.entity.ts +++ b/src/entity/product.entity.ts @@ -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', diff --git a/src/service/dict.service.ts b/src/service/dict.service.ts index 8055007..6facfd6 100644 --- a/src/service/dict.service.ts +++ b/src/service/dict.service.ts @@ -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; + + @InjectEntityModel(Dict) + dictModel: Repository; - @InjectEntityModel(DictItem) - dictItemModel: Repository; + @InjectEntityModel(DictItem) + dictItemModel: Repository; - // 获取字典列表,支持按标题搜索 - 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; + } } diff --git a/src/service/product.service.ts b/src/service/product.service.ts index 1acc7ea..0f8b72d 100644 --- a/src/service/product.service.ts +++ b/src/service/product.service.ts @@ -113,64 +113,37 @@ export class ProductService { name?: string, brandId?: number ): Promise { - 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 { - // 检查是否有产品属于该品牌 - const count = await this.productModel.count({ - where: { brandId }, + + async hasAttribute( + dictName: string, + title: string, + id?: number + ): Promise { + 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 { - // 查找 '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 { + 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 { - const count = await this.productModel.count({ - where: { flavorsId }, - }); - return count > 0; - } - async hasFlavors(title: string, id?: string): Promise { - 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 { - const count = await this.productModel.count({ - where: { strengthId }, - }); - return count > 0; - } + async hasStrength(title: string, id?: string): Promise { const strengthDict = await this.dictModel.findOne({