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 (
+
+
+ 选择模板可一键填充详情长图、故事与卖点;套用后可在下方逐张替换图片。
+
+
+
+
+ );
+}
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index ee61bec..b8e0a03 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -22,7 +22,15 @@ const { Header, Sider, Content } = Layout;
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: , label: '概览' },
{ key: '/users', icon: , label: '用户' },
- { key: '/products', icon: , label: '商品' },
+ {
+ key: 'products-group',
+ icon: ,
+ label: '商品',
+ children: [
+ { key: '/products', label: '商品列表' },
+ { key: '/product-detail-templates', label: '详情模板' },
+ ],
+ },
{ key: '/orders', icon: , label: '订单' },
{ key: '/resources', icon: , label: 'OSS 资源库' },
{
@@ -103,7 +111,7 @@ export default function AdminLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
- defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
+ defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts
index bd4cb84..cbcbd90 100644
--- a/apps/admin-web/src/lib/constants.ts
+++ b/apps/admin-web/src/lib/constants.ts
@@ -86,6 +86,11 @@ export const AROMA_TYPE_LABELS: Record = {
NONGXIANG: '浓香型',
};
+export const DETAIL_TEMPLATE_STATUS_LABELS: Record = {
+ ACTIVE: '启用',
+ DISABLED: '停用',
+};
+
export const LEDGER_TYPE_LABELS: Record = {
GRANT: '发放',
REDEEM: '核销',
diff --git a/apps/admin-web/src/lib/product-detail-templates.ts b/apps/admin-web/src/lib/product-detail-templates.ts
new file mode 100644
index 0000000..95e49f2
--- /dev/null
+++ b/apps/admin-web/src/lib/product-detail-templates.ts
@@ -0,0 +1,64 @@
+/** 商品详情页文案模板(Admin 一键套用) */
+export const TEMPLATE_MAX_DETAIL_IMAGES = 20;
+
+export type ProductDetailTemplateContent = {
+ storyTitle?: string;
+ storyText?: string;
+ features?: Array<{ icon: string; title: string; desc: string }>;
+ /** 模板内置详情长图 URL 列表 */
+ detailImageUrls?: string[];
+ /** 建议详情长图张数(与 detailImageUrls 数量同步,供展示) */
+ suggestedDetailImageCount?: number;
+};
+
+export type ProductDetailTemplate = {
+ id: string;
+ code: string;
+ label: string;
+ description: string;
+ aromaType?: string;
+ content: ProductDetailTemplateContent;
+};
+
+/** API 返回的详情模板 DTO(与 shared-types ProductDetailTemplateDto 对齐) */
+export type ProductDetailTemplateDto = {
+ id: string;
+ code: string;
+ name: string;
+ description?: string | null;
+ aromaType?: string | null;
+ storyTitle?: string | null;
+ storyText?: string | null;
+ features?: Array<{ icon: string; title: string; desc: string }>;
+ detailImageUrls?: string[];
+ suggestedDetailImageCount: number;
+ sortOrder: number;
+ status: 'ACTIVE' | 'DISABLED';
+ createdAt: string;
+ updatedAt: string;
+};
+
+export function mapDtoToProductDetailTemplate(dto: ProductDetailTemplateDto): ProductDetailTemplate {
+ const detailImageUrls = (dto.detailImageUrls ?? []).filter(Boolean);
+ return {
+ id: dto.code,
+ code: dto.code,
+ label: dto.name,
+ description: dto.description ?? '',
+ aromaType: dto.aromaType ?? undefined,
+ content: {
+ storyTitle: dto.storyTitle ?? undefined,
+ storyText: dto.storyText ?? undefined,
+ features: dto.features ?? [],
+ detailImageUrls,
+ suggestedDetailImageCount: detailImageUrls.length || dto.suggestedDetailImageCount,
+ },
+ };
+}
+
+export function getProductDetailTemplate(
+ templates: ProductDetailTemplate[],
+ code: string,
+) {
+ return templates.find((t) => t.code === code || t.id === code);
+}
diff --git a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx
new file mode 100644
index 0000000..8b661c7
--- /dev/null
+++ b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx
@@ -0,0 +1,258 @@
+import { useState } from 'react';
+import {
+ Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
+ Table, Tag, Typography, message,
+} from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+import { request } from '../lib/api';
+import {
+ AROMA_TYPE_LABELS, DETAIL_TEMPLATE_STATUS_LABELS, fmtTime,
+} from '../lib/constants';
+import { useAdminList } from '../lib/useAdminList';
+import DetailImageUrlList from '../components/DetailImageUrlList';
+import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
+import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
+
+type Row = ProductDetailTemplateDto;
+
+type TemplateFormValues = {
+ code: string;
+ name: string;
+ description?: string;
+ aromaType?: string | null;
+ storyTitle?: string;
+ storyText?: string;
+ features?: Array<{ icon?: string; title?: string; desc?: string }>;
+ detailImageUrls?: string[];
+ suggestedDetailImageCount?: number;
+ sortOrder?: number;
+ status?: string;
+};
+
+function mapToForm(d: Record) {
+ const row = d as Row;
+ return {
+ ...row,
+ detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
+ features: row.features?.length
+ ? row.features
+ : [{ icon: 'star', title: '', desc: '' }],
+ };
+}
+
+function buildPayload(v: TemplateFormValues) {
+ const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
+ const features = (v.features ?? [])
+ .filter((f) => f?.title?.trim() || f?.desc?.trim())
+ .map((f) => ({
+ icon: f.icon?.trim() || 'star',
+ title: f.title?.trim() ?? '',
+ desc: f.desc?.trim() ?? '',
+ }));
+ return {
+ code: v.code.trim(),
+ name: v.name.trim(),
+ description: v.description?.trim() || undefined,
+ aromaType: v.aromaType || null,
+ storyTitle: v.storyTitle?.trim() || undefined,
+ storyText: v.storyText?.trim() || undefined,
+ features,
+ detailImageUrls,
+ suggestedDetailImageCount: detailImageUrls.length || (v.suggestedDetailImageCount ?? 1),
+ sortOrder: v.sortOrder ?? 0,
+ status: v.status ?? 'ACTIVE',
+ };
+}
+
+function TemplateContentFields() {
+ 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 (
+
+
+ 商品详情模板
+
+
+
+
+
+
+
+
+
+
+
{ setPage(p); setPageSize(ps); } }} />
+ setDrawerOpen(false)}
+ extra={detail && (
+
+ )}>
+ {detail && (
+ <>
+
+ {String(detail.id)}
+ {fmtTime(String(detail.createdAt))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ 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}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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() {
>
@@ -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,
],
})