feat(admin): redesign GoodsView with dual-tree UX, filter bar, inline CRUD
- Two-line good nodes with country + tag chips, hover tooltip
- Resizable dual-tree with fixed-height panels (no node overlap)
- Filter bar: search, country filter, tag filter (multi-select with rename)
- Mode switch (品类/国家) pushed to right via spacer
- Inline create country/tag in edit & config modals via quick-create buttons
- Right tree node height auto-fix for locate highlight
- Filter dropdown styles in global scope for teleported popper
- Backend: multi-tag (GoodTag junction), goodImage column, origin goods tree API
- Admin response interceptor unwraps {data, success} envelope
- Simplified sidebar to single 商品管理 entry with tabs
- SyncView simplified to product sync only
This commit is contained in:
@@ -73,7 +73,8 @@ model Tag {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
goods Good[]
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
|
||||
@@map("tags")
|
||||
}
|
||||
@@ -106,6 +107,7 @@ model Good {
|
||||
tagId BigInt? @map("tag_id")
|
||||
positionId BigInt? @map("position_id")
|
||||
goodName String @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPriority Int @default(0) @map("good_priority")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
@@ -115,6 +117,7 @@ model Good {
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
goodTags GoodTag[]
|
||||
|
||||
@@index([originGoodId])
|
||||
@@index([countryId])
|
||||
@@ -127,6 +130,20 @@ model Good {
|
||||
@@map("goods")
|
||||
}
|
||||
|
||||
// ---------- Good-Tag Junction (M:N) ----------
|
||||
model GoodTag {
|
||||
goodId BigInt @map("good_id")
|
||||
tagId BigInt @map("tag_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@id([goodId, tagId])
|
||||
@@index([tagId])
|
||||
@@map("good_tags")
|
||||
}
|
||||
|
||||
// ---------- Users (admin authentication) ----------
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
|
||||
@@ -11,6 +11,9 @@ export class CategoryNodeDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sdsCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@@ -22,6 +25,7 @@ export class CategoryNodeDto {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
sdsCategoryId: category.sdsCategoryId,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
|
||||
@@ -40,11 +40,12 @@ export class BatchCreateGoodDto {
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagId?: number;
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@@ -57,4 +58,4 @@ export class BatchCreateGoodDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
defaultPriority?: number;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -28,11 +30,13 @@ export class CreateGoodDto {
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagId?: number;
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@@ -45,4 +49,9 @@ export class CreateGoodDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
}
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export interface GoodRelations {
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@@ -22,6 +23,9 @@ export class GoodDto {
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
goodImage!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@@ -55,6 +59,9 @@ export class GoodDto {
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@@ -74,6 +81,7 @@ export class GoodDto {
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodImage: good.goodImage,
|
||||
goodPriority: good.goodPriority,
|
||||
countryId: good.countryId.toString(),
|
||||
categoryId: good.categoryId.toString(),
|
||||
@@ -103,6 +111,13 @@ export class GoodDto {
|
||||
tagColor: rel.tag.tagColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
? rel.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
@@ -131,4 +146,4 @@ export interface PaginatedGoods {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -30,11 +31,12 @@ export class UpdateGoodDto {
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagId?: number | null;
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@@ -47,4 +49,9 @@ export class UpdateGoodDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
}
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
}
|
||||
@@ -99,13 +99,13 @@ describe('GoodsService', () => {
|
||||
originGoodId: Number(originGoodIds[0]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagId: Number(tagId),
|
||||
tagIds: [Number(tagId)],
|
||||
positionId: Number(positionId),
|
||||
goodPriority: 3,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.tag?.tagColor).toBe('#00FF00');
|
||||
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
|
||||
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
@@ -121,7 +121,9 @@ describe('GoodsService', () => {
|
||||
});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||
expect(result.items.every((g) => g.tagId === tagId.toString())).toBe(true);
|
||||
expect(
|
||||
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('categoryId filter includes descendants recursively', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const GOOD_INCLUDE = {
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
@@ -28,7 +29,7 @@ export class GoodsService {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||
if (tagId !== undefined) where.tagId = BigInt(tagId);
|
||||
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
|
||||
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||
if (keyword) {
|
||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||
@@ -56,6 +57,7 @@ export class GoodsService {
|
||||
tag: g.tag,
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
@@ -75,29 +77,44 @@ export class GoodsService {
|
||||
tag: good.tag,
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
const created = await this.prisma.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(created, {
|
||||
country: created.country,
|
||||
category: created.category,
|
||||
tag: created.tag,
|
||||
position: created.position,
|
||||
originGood: created.originGood,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: created.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,12 +134,6 @@ export class GoodsService {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||
}
|
||||
if (dto.tagId !== undefined) {
|
||||
data.tag =
|
||||
dto.tagId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.tagId) } };
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
data.position =
|
||||
dto.positionId === null
|
||||
@@ -130,17 +141,38 @@ export class GoodsService {
|
||||
: { connect: { id: BigInt(dto.positionId) } };
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
const updated = await this.prisma.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(updated, {
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(updated, {
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,11 +199,16 @@ export class GoodsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagId/positionId
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
|
||||
* and a default priority that may be overridden per item.
|
||||
*/
|
||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||
const defaultPriority = dto.defaultPriority ?? 0;
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
@@ -186,21 +223,33 @@ export class GoodsService {
|
||||
const row = await tx.good.create({
|
||||
data: {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: item.priority ?? defaultPriority,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: row.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
created.push(GoodDto.from(row, {
|
||||
country: row.country,
|
||||
category: row.category,
|
||||
tag: row.tag,
|
||||
position: row.position,
|
||||
originGood: row.originGood,
|
||||
created.push(GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
@@ -245,17 +294,23 @@ export class GoodsService {
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureTag(id: number) {
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureReferences(dto: CreateGoodDto) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.tagId !== undefined) {
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(dto.tagId) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${dto.tagId} not found`);
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@ import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
export class OriginGoodsController {
|
||||
constructor(private readonly service: OriginGoodsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@ApiOperation({
|
||||
summary: 'Origin goods grouped by SDS category with config status',
|
||||
})
|
||||
getTree() {
|
||||
return this.service.getTree();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
|
||||
@@ -19,6 +19,39 @@ export interface PaginatedOriginGoods {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single origin-good node inside the tree, augmented with configuration status
|
||||
* (how many `goods` rows reference it and which countries it has been configured for).
|
||||
*/
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsGoodId: string;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null }[];
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
sdsCategoryId: string | null;
|
||||
configuredCount: number;
|
||||
totalCount: number;
|
||||
children: OriginGoodsTreeCategoryNode[];
|
||||
originGoods: OriginGoodsTreeNode[];
|
||||
}
|
||||
|
||||
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[];
|
||||
totalOriginGoods: number;
|
||||
configuredCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -55,4 +88,169 @@ export class OriginGoodsService {
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree using the `categories` table parent-child
|
||||
* structure, placing each origin-good as a leaf under the category whose
|
||||
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
||||
*
|
||||
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
||||
* under a synthetic "未分类" root node.
|
||||
*/
|
||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
||||
await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
orderBy: { categoryName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
categoryName: true,
|
||||
sdsCategoryId: true,
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.originGood.findMany({ orderBy: { goodName: 'asc' } }),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.good.findMany({
|
||||
select: {
|
||||
originGoodId: true,
|
||||
country: { select: { countryName: true } },
|
||||
},
|
||||
distinct: ['originGoodId', 'countryId'],
|
||||
}),
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: { select: { tagName: true, tagColor: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
configCounts.forEach((c) =>
|
||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||
);
|
||||
|
||||
const countryMap = new Map<string, string[]>();
|
||||
goodsWithCountries.forEach((g) => {
|
||||
const key = g.originGoodId.toString();
|
||||
const name = g.country?.countryName;
|
||||
if (!name) return;
|
||||
const arr = countryMap.get(key);
|
||||
if (arr) arr.push(name);
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor };
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
} else {
|
||||
tagMap.set(key, [tagInfo]);
|
||||
}
|
||||
});
|
||||
|
||||
const sdsToCategory = new Map<
|
||||
string,
|
||||
(typeof allCategories)[number]
|
||||
>();
|
||||
for (const c of allCategories) {
|
||||
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
||||
}
|
||||
|
||||
const ogToCategory = new Map<string, string>();
|
||||
for (const og of allOriginGoods) {
|
||||
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
||||
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
cat: (typeof allCategories)[number],
|
||||
): OriginGoodsTreeCategoryNode => {
|
||||
const childrenCats = allCategories.filter(
|
||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||
);
|
||||
const childNodes = childrenCats.map(buildNode);
|
||||
|
||||
const ogsForThisCat = allOriginGoods.filter(
|
||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||
);
|
||||
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
const childConfigured = childNodes.reduce(
|
||||
(s, n) => s + n.configuredCount,
|
||||
0,
|
||||
);
|
||||
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
||||
|
||||
return {
|
||||
categoryId: cat.id.toString(),
|
||||
categoryName: cat.categoryName,
|
||||
sdsCategoryId: cat.sdsCategoryId,
|
||||
configuredCount: childConfigured + ogConfigured,
|
||||
totalCount: childTotal + ogNodes.length,
|
||||
children: childNodes,
|
||||
originGoods: ogNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||
const tree = roots.map(buildNode);
|
||||
|
||||
const unmapped = allOriginGoods.filter(
|
||||
(og) => !ogToCategory.has(og.id.toString()),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
tree.push({
|
||||
categoryId: 'uncategorized',
|
||||
categoryName: '未分类',
|
||||
sdsCategoryId: null,
|
||||
configuredCount: unmapped.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length,
|
||||
totalCount: unmapped.length,
|
||||
children: [],
|
||||
originGoods: unmapped.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
||||
|
||||
const totalConfigured = allOriginGoods.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length;
|
||||
|
||||
return {
|
||||
tree,
|
||||
totalOriginGoods: allOriginGoods.length,
|
||||
configuredCount: totalConfigured,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export class PublicGoodDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@@ -30,4 +33,4 @@ export class PublicGoodDto {
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ export interface PublicPaginatedGoods {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const PUBLIC_GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -38,7 +47,9 @@ export class PublicService {
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
||||
if (query.tagId !== undefined) where.tagId = BigInt(query.tagId);
|
||||
if (query.tagId !== undefined) {
|
||||
where.goodTags = { some: { tagId: BigInt(query.tagId) } };
|
||||
}
|
||||
if (query.keyword) {
|
||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
}
|
||||
@@ -51,13 +62,7 @@ export class PublicService {
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
// Server-side primary sort; PublicGoodDto retains original indexes
|
||||
// for stable pagination but the final ORDER BY is mirrored below.
|
||||
orderBy: [
|
||||
@@ -81,13 +86,7 @@ export class PublicService {
|
||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return this.toPublicGood(good);
|
||||
@@ -96,6 +95,7 @@ export class PublicService {
|
||||
private toPublicGood(good: {
|
||||
id: bigint;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPriority: number;
|
||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
||||
@@ -105,6 +105,7 @@ export class PublicService {
|
||||
goodImage: string | null;
|
||||
goodPrice: { toString(): string } | null;
|
||||
} | null;
|
||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||
createdAt: Date;
|
||||
}): PublicGoodDto {
|
||||
return {
|
||||
@@ -128,13 +129,18 @@ export class PublicService {
|
||||
tagColor: good.tag.tagColor,
|
||||
}
|
||||
: null,
|
||||
tags: good.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
})),
|
||||
position: good.position
|
||||
? {
|
||||
id: good.position.id.toString(),
|
||||
indexVal: good.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
image: good.originGood?.goodImage ?? null,
|
||||
image: good.goodImage ?? good.originGood?.goodImage ?? null,
|
||||
price:
|
||||
good.originGood?.goodPrice === null ||
|
||||
good.originGood?.goodPrice === undefined
|
||||
@@ -180,4 +186,4 @@ export class PublicService {
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user