diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 669356b..f5f82ef 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage'; import CitiesPage from './pages/CitiesPage'; import StoreMediaPage from './pages/StoreMediaPage'; import ProductsPage from './pages/ProductsPage'; +import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage'; import ResourcesPage from './pages/ResourcesPage'; import StorePayoutsPage from './pages/StorePayoutsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/components/DetailImageUrlList.tsx b/apps/admin-web/src/components/DetailImageUrlList.tsx new file mode 100644 index 0000000..b430ba0 --- /dev/null +++ b/apps/admin-web/src/components/DetailImageUrlList.tsx @@ -0,0 +1,49 @@ +import { Button, Form, Space, Typography } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import OssUpload from './OssUpload'; + +type Props = { + name?: string; + label: string; + bizType?: string; + /** 最多可添加张数;不传则不限制 */ + maxCount?: number; +}; + +export default function DetailImageUrlList({ + name = 'detailImageUrls', + label, + bizType = 'DETAIL', + maxCount, +}: Props) { + return ( + <> + {maxCount != null && ( + + 最多 {maxCount} 张{label} + + )} + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 8 }} /> + )} + + ))} + {(!maxCount || fields.length < maxCount) && ( + + )} + + )} + + + ); +} diff --git a/apps/admin-web/src/components/ProductDetailTemplatePicker.tsx b/apps/admin-web/src/components/ProductDetailTemplatePicker.tsx new file mode 100644 index 0000000..4e33335 --- /dev/null +++ b/apps/admin-web/src/components/ProductDetailTemplatePicker.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd'; +import type { FormInstance } from 'antd/es/form'; +import { request, type Paginated } from '../lib/api'; +import { + getProductDetailTemplate, + mapDtoToProductDetailTemplate, + type ProductDetailTemplate, + type ProductDetailTemplateDto, +} from '../lib/product-detail-templates'; + +type Props = { + form: FormInstance; + /** 当前商品香型,用于推荐匹配模板 */ + aromaType?: string; +}; + +function hasDetailContent(form: FormInstance) { + const storyTitle = form.getFieldValue('storyTitle') as string | undefined; + const storyText = form.getFieldValue('storyText') as string | undefined; + const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined; + const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined; + const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim()); + const hasImages = (detailImageUrls ?? []).some((u) => u?.trim()); + return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages); +} + +function applyTemplate(form: FormInstance, template: ProductDetailTemplate) { + const { content } = template; + const detailImageUrls = content.detailImageUrls?.length + ? [...content.detailImageUrls] + : ['']; + form.setFieldsValue({ + storyTitle: content.storyTitle ?? '', + storyText: content.storyText ?? '', + detailImageUrls, + features: + content.features && content.features.length > 0 + ? content.features.map((f) => ({ ...f })) + : [{ icon: 'star', title: '', desc: '' }], + }); + const imageHint = detailImageUrls.filter(Boolean).length; + message.success( + imageHint > 0 + ? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)` + : `已应用模板「${template.label}」`, + ); +} + +export default function ProductDetailTemplatePicker({ form, aromaType }: Props) { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(); + + useEffect(() => { + let cancelled = false; + setLoading(true); + request>( + '/admin/product-detail-templates?status=ACTIVE&pageSize=100', + ) + .then((res) => { + if (cancelled) return; + const mapped = res.items.map(mapDtoToProductDetailTemplate); + setTemplates(mapped); + const defaultId = + (aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) || + mapped.find((t) => t.code === 'dukang-classic')?.id || + mapped[0]?.id; + setSelectedId(defaultId); + }) + .catch(() => { + if (!cancelled) message.error('加载详情模板失败'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [aromaType]); + + const selected = getProductDetailTemplate(templates, selectedId ?? ''); + + function doApply() { + if (!selected) return; + applyTemplate(form, selected); + } + + function handleApply() { + if (!selected) return; + if (hasDetailContent(form)) { + return; + } + doApply(); + } + + if (loading) { + return ( + + + + ); + } + + if (templates.length === 0) { + return ( + + 暂无可用模板,请先在「详情模板」菜单中创建。 + + ); + } + + return ( + + + 选择模板可一键填充详情长图、故事与卖点;套用后可在下方逐张替换图片。 + + + + + + + + 卖点特色 + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 30 }} /> + )} + + + + + + ))} + + + )} + + + ); +} + +export default function ProductDetailTemplatesPage() { + const [form] = Form.useForm(); + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/product-detail-templates', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.code) qs.set('code', filters.code); + if (filters.status) qs.set('status', filters.status); + if (filters.aromaType) qs.set('aromaType', filters.aromaType); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + + const columns: ColumnsType = [ + { title: '编码', dataIndex: 'code', width: 120 }, + { title: '名称', dataIndex: 'name', width: 120 }, + { title: '说明', dataIndex: 'description', width: 200, ellipsis: true }, + { title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') }, + { title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 }, + { title: '排序', dataIndex: 'sortOrder', width: 60 }, + { title: '状态', dataIndex: 'status', width: 80, render: (s) => {DETAIL_TEMPLATE_STATUS_LABELS[s] || s} }, + { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, + { + title: '操作', width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ + 商品详情模板 + + +
{ setFilters(v); setPage(1); }}> + + + + ({ value, label }))} /> + + +
+ { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( + + )}> + {detail && ( + <> + + {String(detail.id)} + {fmtTime(String(detail.createdAt))} + +
+ + + + + + + + + + + ({ value, label }))} /> + + + + + + )} +
+ setCreateOpen(false)} onOk={async () => { + const v = await createForm.validateFields(); + const payload = buildPayload(v); + await request('/admin/product-detail-templates', { method: 'POST', body: JSON.stringify(payload) }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + }} width={640}> +
+ + + + + + + + + + + ({ value, label }))} /> + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index fdedc3b..2db87d6 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -9,6 +9,9 @@ import { request } from '../lib/api'; import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import DetailImageUrlList from '../components/DetailImageUrlList'; +import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker'; +import type { FormInstance } from 'antd/es/form'; type ProductDetailContentDto = { storyTitle?: string; @@ -128,14 +131,16 @@ function ImageUrlList({ name, label, bizType }: { name: string; label: string; b ); } -function ProductDetailFields() { +function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaType?: string }) { return ( <> + + 详情页轮播(CAROUSEL) - 详情长图(DETAIL) - + 详情长图(DETAIL,可逐张修改) + @@ -301,7 +306,16 @@ export default function ProductsPage() {
}, - { key: 'detail', label: '详情页', children: }, + { + key: 'detail', + label: '详情页', + children: ( + + ), + }, ]} /> @@ -323,7 +337,20 @@ export default function ProductsPage() { }}> }, - { key: 'detail', label: '详情页', children: }, + { + key: 'detail', + label: '详情页', + children: ( + prev.aromaType !== cur.aromaType}> + {() => ( + + )} + + ), + }, ]} /> diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index 9307ed5..079515c 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -43,3 +43,20 @@ export interface ProductListQuery { cityCode?: string; aromaType?: string; } + +export interface ProductDetailTemplateDto { + id: string; + code: string; + name: string; + description?: string | null; + aromaType?: string | null; + storyTitle?: string | null; + storyText?: string | null; + features?: ProductDetailFeatureDto[]; + detailImageUrls?: string[]; + suggestedDetailImageCount: number; + sortOrder: number; + status: 'ACTIVE' | 'DISABLED'; + createdAt: string; + updatedAt: string; +} diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 0a8334e..287771e 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -90,6 +90,11 @@ enum ProductStatus { OFF_SALE } +enum DetailTemplateStatus { + ACTIVE + DISABLED +} + enum PromoCodeStatus { ACTIVE DISABLED @@ -333,6 +338,26 @@ model CommonProductItem { @@map("common_product_item") } +model CommonProductDetailTemplate { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(64) + description String? @db.VarChar(256) + aromaType AromaType? @map("aroma_type") + storyTitle String? @map("story_title") @db.VarChar(128) + storyText String? @map("story_text") @db.Text + features Json? + detailImageUrls Json? @map("detail_image_urls") + suggestedDetailImageCount Int @default(1) @map("suggested_detail_image_count") + sortOrder Int @default(0) @map("sort_order") + status DetailTemplateStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + @@index([status, sortOrder]) + @@map("common_product_detail_template") +} + model CommonStoreCategory { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt code String @unique @db.VarChar(32) diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index a336edf..792d36f 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -1,4 +1,5 @@ import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; +import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default'; const prisma = new PrismaClient(); @@ -46,6 +47,7 @@ async function main() { await prisma.commonCity.deleteMany(); await prisma.partner.deleteMany(); await prisma.commonProductItem.deleteMany(); + await prisma.commonProductDetailTemplate.deleteMany(); await prisma.commonStoreCategory.deleteMany(); await prisma.commonPromoCode.deleteMany(); await prisma.commonResource.deleteMany(); @@ -89,6 +91,24 @@ async function main() { prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }), ]); + for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) { + await prisma.commonProductDetailTemplate.create({ + data: { + code: tpl.code, + name: tpl.name, + description: tpl.description, + aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null, + storyTitle: tpl.storyTitle, + storyText: tpl.storyText, + features: tpl.features, + detailImageUrls: [], + suggestedDetailImageCount: tpl.suggestedDetailImageCount, + sortOrder: tpl.sortOrder, + status: 'ACTIVE', + }, + }); + } + const productDefs = [ { skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' }, { skuCode: 'QX-002', name: '杜康·年份陈酿(十年)', subtitle: '清香型 42度 纯粮酿造', price: 880, sortOrder: 2, img: 'https://picsum.photos/seed/dukang2/400/400' }, @@ -227,6 +247,7 @@ async function main() { console.log('Seed complete:', { city: city.name, + detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length, products: products.length, stores: storeDefs.length, testPhones: { diff --git a/server/dukang-api/prisma/seeds/product-detail-templates.default.ts b/server/dukang-api/prisma/seeds/product-detail-templates.default.ts new file mode 100644 index 0000000..85fd81e --- /dev/null +++ b/server/dukang-api/prisma/seeds/product-detail-templates.default.ts @@ -0,0 +1,89 @@ +/** 商品详情模板默认种子(与 admin-web 历史静态模板一致) */ +export const DEFAULT_PRODUCT_DETAIL_TEMPLATES = [ + { + code: 'dukang-classic', + name: '杜康经典', + description: '品牌故事 + 双卖点,适合主力 SKU', + aromaType: null as string | null, + storyTitle: '千年杜康 · 唯有此处', + storyText: + '选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。', + features: [ + { icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' }, + { icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' }, + ], + suggestedDetailImageCount: 2, + sortOrder: 1, + }, + { + code: 'qingxiang', + name: '清香型', + description: '突出清香甘冽、入口绵柔', + aromaType: 'QINGXIANG', + storyTitle: '清香传世 · 入口甘冽', + storyText: + '以优质高粱、大麦、豌豆为原料,地缸固态发酵,酒体清澈透明,清香纯正,甘冽爽口,余味悠长。', + features: [ + { icon: 'air', title: '清香纯正', desc: '窖香优雅 入口绵甜' }, + { icon: 'local_drink', title: '纯粮固态', desc: '地缸发酵 传统工艺' }, + ], + suggestedDetailImageCount: 2, + sortOrder: 2, + }, + { + code: 'jiangxiang', + name: '酱香型', + description: '突出酱香突出、回味悠长', + aromaType: 'JIANGXIANG', + storyTitle: '酱香典范 · 岁月醇香', + storyText: + '遵循端午制曲、重阳下沙,九次蒸煮八次发酵,长期窖藏陈化,酱香突出,幽雅细腻,空杯留香持久。', + features: [ + { icon: 'schedule', title: '陈年窖藏', desc: '时光淬炼 醇厚丰满' }, + { icon: 'spa', title: '酱香工艺', desc: '九蒸八酵 匠心酿造' }, + ], + suggestedDetailImageCount: 3, + sortOrder: 3, + }, + { + code: 'nongxiang', + name: '浓香型', + description: '突出窖香浓郁、绵甜爽净', + aromaType: 'NONGXIANG', + storyTitle: '浓香典范 · 窖香天成', + storyText: + '泥窖固态发酵,续糟配料,蒸馏摘酒,分级贮存。酒体无色透明,窖香浓郁,绵甜爽净,尾净余香长。', + features: [ + { icon: 'foundation', title: '百年窖池', desc: '微生物群落 窖香天成' }, + { icon: 'water_drop', title: '绵甜爽净', desc: '入口醇和 回味悠长' }, + ], + suggestedDetailImageCount: 2, + sortOrder: 4, + }, + { + code: 'benefit-focus', + name: '好客权益', + description: '强调购酒享权益、到店核销', + aromaType: null, + storyTitle: '买杜康美酒 · 享全城好客礼遇', + storyText: + '购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。', + features: [ + { icon: 'confirmation_number', title: '购酒发券', desc: '支付成功即享权益额度' }, + { icon: 'store', title: '全城好店', desc: '本地签约门店随心核销' }, + ], + suggestedDetailImageCount: 1, + sortOrder: 5, + }, + { + code: 'minimal', + name: '简约留白', + description: '仅故事正文,无卖点模块', + aromaType: null, + storyTitle: '商品详情', + storyText: '请在上方上传详情长图,或在此补充商品说明文字。', + features: [] as Array<{ icon: string; title: string; desc: string }>, + suggestedDetailImageCount: 1, + sortOrder: 6, + }, +] as const; diff --git a/server/dukang-api/prisma/upsert-product-detail-templates.ts b/server/dukang-api/prisma/upsert-product-detail-templates.ts new file mode 100644 index 0000000..bfa0d27 --- /dev/null +++ b/server/dukang-api/prisma/upsert-product-detail-templates.ts @@ -0,0 +1,46 @@ +import { PrismaClient } from '@prisma/client'; +import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default'; + +const prisma = new PrismaClient(); + +async function main() { + for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) { + await prisma.commonProductDetailTemplate.upsert({ + where: { code: tpl.code }, + create: { + code: tpl.code, + name: tpl.name, + description: tpl.description, + aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null, + storyTitle: tpl.storyTitle, + storyText: tpl.storyText, + features: tpl.features, + detailImageUrls: [], + suggestedDetailImageCount: tpl.suggestedDetailImageCount, + sortOrder: tpl.sortOrder, + status: 'ACTIVE', + }, + update: { + name: tpl.name, + description: tpl.description, + aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null, + storyTitle: tpl.storyTitle, + storyText: tpl.storyText, + features: tpl.features, + detailImageUrls: [], + suggestedDetailImageCount: tpl.suggestedDetailImageCount, + sortOrder: tpl.sortOrder, + }, + }); + } + console.log(`Upserted ${DEFAULT_PRODUCT_DETAIL_TEMPLATES.length} product detail templates`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 5e2edb5..f8c573f 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -83,6 +83,6 @@ import type { ISmsProvider } from './sms/sms.interface'; TencentLbsProvider, { provide: MAP_PROVIDER, useExisting: TencentLbsProvider }, ], - exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, CourierModule], + exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, TencentLbsProvider, CourierModule], }) export class IntegrationsModule {} diff --git a/server/dukang-api/src/modules/ops/admin-product-detail-templates.controller.ts b/server/dukang-api/src/modules/ops/admin-product-detail-templates.controller.ts new file mode 100644 index 0000000..4ba9985 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-product-detail-templates.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service'; +import { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto'; +import { + CreateProductDetailTemplateDto, + UpdateProductDetailTemplateDto, +} from './dto/admin-mutate.dto'; + +@Controller('admin/product-detail-templates') +@UseGuards(HqAuthGuard) +export class AdminProductDetailTemplatesController { + constructor(private readonly service: AdminProductDetailTemplatesService) {} + + @Get() + list(@Query() query: AdminProductDetailTemplatesQueryDto) { + return this.service.list(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } + + @Post() + create(@Body() dto: CreateProductDetailTemplateDto) { + return this.service.create(dto); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateProductDetailTemplateDto) { + return this.service.update(BigInt(id), dto); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts new file mode 100644 index 0000000..d648fa6 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts @@ -0,0 +1,183 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto'; +import type { + CreateProductDetailTemplateDto, + UpdateProductDetailTemplateDto, +} from './dto/admin-mutate.dto'; + +type TemplateRow = { + id: bigint; + code: string; + name: string; + description: string | null; + aromaType: string | null; + storyTitle: string | null; + storyText: string | null; + features: unknown; + detailImageUrls?: unknown; + suggestedDetailImageCount: number; + sortOrder: number; + status: string; + createdAt: Date; + updatedAt: Date; +}; + +const MAX_TEMPLATE_DETAIL_IMAGES = 20; + +@Injectable() +export class AdminProductDetailTemplatesService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: AdminProductDetailTemplatesQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.CommonProductDetailTemplateWhereInput = {}; + if (query.name) where.name = { contains: query.name }; + if (query.code) where.code = { contains: query.code }; + if (query.status) { + where.status = query.status as Prisma.EnumDetailTemplateStatusFilter['equals']; + } + if (query.aromaType) { + where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals']; + } + + const [items, total] = await Promise.all([ + this.prisma.commonProductDetailTemplate.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.commonProductDetailTemplate.count({ where }), + ]); + + return serializeBigInt({ + items: items.map((row) => this.format(row)), + total, + page, + pageSize, + }); + } + + async detail(id: bigint) { + const row = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('详情模板不存在'); + return serializeBigInt(this.format(row)); + } + + async create(dto: CreateProductDetailTemplateDto) { + const exists = await this.prisma.commonProductDetailTemplate.findUnique({ + where: { code: dto.code }, + }); + if (exists) throw new BadRequestException('模板编码已存在'); + + const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls); + + const row = await this.prisma.commonProductDetailTemplate.create({ + data: { + code: dto.code, + name: dto.name, + description: dto.description, + aromaType: dto.aromaType as Prisma.CommonProductDetailTemplateCreateInput['aromaType'], + storyTitle: dto.storyTitle, + storyText: dto.storyText, + features: this.normalizeFeatures(dto.features) as Prisma.InputJsonValue, + detailImageUrls: detailImageUrls as Prisma.InputJsonValue, + suggestedDetailImageCount: + detailImageUrls.length > 0 ? detailImageUrls.length : (dto.suggestedDetailImageCount ?? 1), + sortOrder: dto.sortOrder ?? 0, + status: (dto.status ?? 'ACTIVE') as Prisma.CommonProductDetailTemplateCreateInput['status'], + } as Prisma.CommonProductDetailTemplateCreateInput, + }); + return serializeBigInt(this.format(row)); + } + + async update(id: bigint, dto: UpdateProductDetailTemplateDto) { + const existing = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('详情模板不存在'); + + if (dto.code && dto.code !== existing.code) { + const dup = await this.prisma.commonProductDetailTemplate.findUnique({ where: { code: dto.code } }); + if (dup) throw new BadRequestException('模板编码已存在'); + } + + const data: Prisma.CommonProductDetailTemplateUpdateInput = {}; + if (dto.code !== undefined) data.code = dto.code; + if (dto.name !== undefined) data.name = dto.name; + if (dto.description !== undefined) data.description = dto.description; + if (dto.aromaType !== undefined) { + data.aromaType = dto.aromaType as Prisma.CommonProductDetailTemplateUpdateInput['aromaType']; + } + if (dto.storyTitle !== undefined) data.storyTitle = dto.storyTitle; + if (dto.storyText !== undefined) data.storyText = dto.storyText; + if (dto.features !== undefined) { + data.features = this.normalizeFeatures(dto.features) as Prisma.InputJsonValue; + } + if (dto.detailImageUrls !== undefined) { + const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls); + (data as Prisma.CommonProductDetailTemplateUpdateInput & { detailImageUrls?: Prisma.InputJsonValue }).detailImageUrls = + detailImageUrls as Prisma.InputJsonValue; + data.suggestedDetailImageCount = + detailImageUrls.length > 0 + ? detailImageUrls.length + : (dto.suggestedDetailImageCount ?? existing.suggestedDetailImageCount); + } else if (dto.suggestedDetailImageCount !== undefined) { + data.suggestedDetailImageCount = dto.suggestedDetailImageCount; + } + if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder; + if (dto.status !== undefined) { + data.status = dto.status as Prisma.CommonProductDetailTemplateUpdateInput['status']; + } + + const row = await this.prisma.commonProductDetailTemplate.update({ where: { id }, data }); + return serializeBigInt(this.format(row)); + } + + private normalizeDetailImageUrls(urls?: string[]) { + if (!urls) return []; + const cleaned = urls.map((u) => u?.trim()).filter(Boolean); + if (cleaned.length > MAX_TEMPLATE_DETAIL_IMAGES) { + throw new BadRequestException(`详情图最多 ${MAX_TEMPLATE_DETAIL_IMAGES} 张`); + } + return cleaned; + } + + private normalizeFeatures(features?: Array<{ icon: string; title: string; desc: string }>) { + if (!features) return []; + return features + .filter((f) => f.title?.trim() || f.desc?.trim()) + .map((f) => ({ + icon: f.icon?.trim() || 'star', + title: f.title?.trim() ?? '', + desc: f.desc?.trim() ?? '', + })); + } + + private format(row: TemplateRow) { + const features = Array.isArray(row.features) + ? (row.features as Array<{ icon: string; title: string; desc: string }>) + : []; + const detailImageUrls = Array.isArray(row.detailImageUrls) + ? (row.detailImageUrls as string[]).filter(Boolean) + : []; + return { + id: row.id.toString(), + code: row.code, + name: row.name, + description: row.description, + aromaType: row.aromaType, + storyTitle: row.storyTitle, + storyText: row.storyText, + features, + detailImageUrls, + suggestedDetailImageCount: detailImageUrls.length || row.suggestedDetailImageCount, + sortOrder: row.sortOrder, + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 019e2ef..b7d58fd 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -454,3 +454,108 @@ export class UpdateProductDto { @IsObject() detailContent?: Record; } + +class ProductDetailFeatureInputDto { + @IsString() + icon: string; + + @IsString() + title: string; + + @IsString() + desc: string; +} + +export class CreateProductDetailTemplateDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG']) + aromaType?: string; + + @IsOptional() + @IsString() + storyTitle?: string; + + @IsOptional() + @IsString() + storyText?: string; + + @IsOptional() + @IsArray() + features?: ProductDetailFeatureInputDto[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + detailImageUrls?: string[]; + + @IsOptional() + @IsNumber() + suggestedDetailImageCount?: number; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: string; +} + +export class UpdateProductDetailTemplateDto { + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG', null]) + aromaType?: string | null; + + @IsOptional() + @IsString() + storyTitle?: string; + + @IsOptional() + @IsString() + storyText?: string; + + @IsOptional() + @IsArray() + features?: ProductDetailFeatureInputDto[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + detailImageUrls?: string[]; + + @IsOptional() + @IsNumber() + suggestedDetailImageCount?: number; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: string; +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 95f86ef..78e32a1 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -227,6 +227,24 @@ export class AdminProductsQueryDto extends PaginationQueryDto { aromaType?: string; } +export class AdminProductDetailTemplatesQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + aromaType?: string; +} + export class AdminUserLogsQueryDto extends PaginationQueryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index c12c74d..e89392b 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -31,6 +31,8 @@ import { CommonModule } from '../common/common.module'; import { IntegrationsModule } from '../../integrations/integrations.module'; import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; +import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller'; +import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service'; @Module({ imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule], @@ -53,6 +55,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; AdminUserLogsController, AdminTicketsController, AdminXiaofeixiaController, + AdminProductDetailTemplatesController, ], providers: [ AdminDashboardService, @@ -69,6 +72,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; AdminUserLogsService, AdminTicketsService, AdminXiaofeixiaService, + AdminProductDetailTemplatesService, SuperAdminGuard, ], })