商品模板功能上传
This commit is contained in:
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
|
|||||||
import CitiesPage from './pages/CitiesPage';
|
import CitiesPage from './pages/CitiesPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
import ProductsPage from './pages/ProductsPage';
|
import ProductsPage from './pages/ProductsPage';
|
||||||
|
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
@@ -45,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
<Route path="/stores" element={<StoresPage />} />
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
最多 {maxCount} 张{label}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Form.List name={name}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||||
|
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||||
|
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
{(!maxCount || fields.length < maxCount) && (
|
||||||
|
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||||
|
添加{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ProductDetailTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
request<Paginated<ProductDetailTemplateDto>>(
|
||||||
|
'/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 (
|
||||||
|
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary">暂无可用模板,请先在「详情模板」菜单中创建。</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
选择模板可一键填充详情长图、故事与卖点;套用后可在下方逐张替换图片。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="选择模板"
|
||||||
|
value={selectedId}
|
||||||
|
onChange={setSelectedId}
|
||||||
|
options={templates.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: t.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{selected && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={selected.description}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
{selected.content.storyTitle && (
|
||||||
|
<div><strong>标题:</strong>{selected.content.storyTitle}</div>
|
||||||
|
)}
|
||||||
|
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
详情图:<strong>{selected.content.detailImageUrls?.length}</strong> 张(套用后可逐张修改)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(selected.content.features?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
卖点:{selected.content.features?.map((f) => f.title).join('、')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Space>
|
||||||
|
{hasDetailContent(form) ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="将覆盖当前详情图、故事与卖点,是否继续?"
|
||||||
|
onConfirm={doApply}
|
||||||
|
okText="覆盖应用"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="primary">应用模板</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" onClick={handleApply} disabled={!selected}>
|
||||||
|
应用模板
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{aromaType && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const matched = templates.find((t) => t.aromaType === aromaType);
|
||||||
|
if (matched) {
|
||||||
|
setSelectedId(matched.id);
|
||||||
|
if (!hasDetailContent(form)) {
|
||||||
|
applyTemplate(form, matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
按香型推荐
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,15 @@ const { Header, Sider, Content } = Layout;
|
|||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
{ key: '/products', icon: <ShoppingOutlined />, label: '商品' },
|
{
|
||||||
|
key: 'products-group',
|
||||||
|
icon: <ShoppingOutlined />,
|
||||||
|
label: '商品',
|
||||||
|
children: [
|
||||||
|
{ key: '/products', label: '商品列表' },
|
||||||
|
{ key: '/product-detail-templates', label: '详情模板' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
@@ -103,7 +111,7 @@ export default function AdminLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
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}
|
items={MENU_ITEMS}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key.startsWith('/')) navigate(key);
|
if (key.startsWith('/')) navigate(key);
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export const AROMA_TYPE_LABELS: Record<string, string> = {
|
|||||||
NONGXIANG: '浓香型',
|
NONGXIANG: '浓香型',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DETAIL_TEMPLATE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: '启用',
|
||||||
|
DISABLED: '停用',
|
||||||
|
};
|
||||||
|
|
||||||
export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||||
GRANT: '发放',
|
GRANT: '发放',
|
||||||
REDEEM: '核销',
|
REDEEM: '核销',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
||||||
|
<DetailImageUrlList
|
||||||
|
label="详情图"
|
||||||
|
bizType="DETAIL_TEMPLATE"
|
||||||
|
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storyText" label="故事正文">
|
||||||
|
<Input.TextArea rows={4} placeholder="商品故事描述" />
|
||||||
|
</Form.Item>
|
||||||
|
<Typography.Text type="secondary">卖点特色</Typography.Text>
|
||||||
|
<Form.List name="features">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
|
||||||
|
<Space align="start">
|
||||||
|
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
|
||||||
|
<Input placeholder="material icon 名" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
|
||||||
|
<Input placeholder="标题" />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
|
||||||
|
<Input placeholder="简短描述" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
|
||||||
|
添加卖点
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailTemplatesPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/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<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ 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) => <Tag>{DETAIL_TEMPLATE_STATUS_LABELS[s] || s}</Tag> },
|
||||||
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作', width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={async () => {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue(mapToForm(d));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>编辑</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>商品详情模板</Typography.Title>
|
||||||
|
<Button type="primary" onClick={() => setCreateOpen(true)}>新建模板</Button>
|
||||||
|
</Space>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||||
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 110 }} options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="aromaType" label="香型">
|
||||||
|
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||||
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
|
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={detail && (
|
||||||
|
<Button type="primary" onClick={async () => {
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
const payload = buildPayload(v);
|
||||||
|
await request(`/admin/product-detail-templates/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
}}>保存</Button>
|
||||||
|
)}>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="说明">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="aromaType" label="推荐香型">
|
||||||
|
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Divider />
|
||||||
|
<TemplateContentFields />
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
<Modal title="新建详情模板" open={createOpen} onCancel={() => 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}>
|
||||||
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
|
status: 'ACTIVE', sortOrder: 0,
|
||||||
|
detailImageUrls: [''],
|
||||||
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
|
}}>
|
||||||
|
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="说明">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="aromaType" label="推荐香型">
|
||||||
|
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Divider />
|
||||||
|
<TemplateContentFields />
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { request } from '../lib/api';
|
|||||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
storyTitle?: string;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||||
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
||||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情长图(DETAIL)</Typography.Text>
|
<Typography.Text type="secondary">详情长图(DETAIL,可逐张修改)</Typography.Text>
|
||||||
<ImageUrlList name="detailImageUrls" label="详情图" bizType="DETAIL" />
|
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -301,7 +306,16 @@ export default function ProductsPage() {
|
|||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
||||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
{
|
||||||
|
key: 'detail',
|
||||||
|
label: '详情页',
|
||||||
|
children: (
|
||||||
|
<ProductDetailFields
|
||||||
|
form={editForm}
|
||||||
|
aromaType={String(detail.aromaType ?? '')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]} />
|
]} />
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
@@ -323,7 +337,20 @@ export default function ProductsPage() {
|
|||||||
}}>
|
}}>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
{
|
||||||
|
key: 'detail',
|
||||||
|
label: '详情页',
|
||||||
|
children: (
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.aromaType !== cur.aromaType}>
|
||||||
|
{() => (
|
||||||
|
<ProductDetailFields
|
||||||
|
form={createForm}
|
||||||
|
aromaType={createForm.getFieldValue('aromaType') as string | undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
]} />
|
]} />
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -43,3 +43,20 @@ export interface ProductListQuery {
|
|||||||
cityCode?: string;
|
cityCode?: string;
|
||||||
aromaType?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ enum ProductStatus {
|
|||||||
OFF_SALE
|
OFF_SALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DetailTemplateStatus {
|
||||||
|
ACTIVE
|
||||||
|
DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
enum PromoCodeStatus {
|
enum PromoCodeStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
DISABLED
|
DISABLED
|
||||||
@@ -333,6 +338,26 @@ model CommonProductItem {
|
|||||||
@@map("common_product_item")
|
@@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 {
|
model CommonStoreCategory {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||||
|
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ async function main() {
|
|||||||
await prisma.commonCity.deleteMany();
|
await prisma.commonCity.deleteMany();
|
||||||
await prisma.partner.deleteMany();
|
await prisma.partner.deleteMany();
|
||||||
await prisma.commonProductItem.deleteMany();
|
await prisma.commonProductItem.deleteMany();
|
||||||
|
await prisma.commonProductDetailTemplate.deleteMany();
|
||||||
await prisma.commonStoreCategory.deleteMany();
|
await prisma.commonStoreCategory.deleteMany();
|
||||||
await prisma.commonPromoCode.deleteMany();
|
await prisma.commonPromoCode.deleteMany();
|
||||||
await prisma.commonResource.deleteMany();
|
await prisma.commonResource.deleteMany();
|
||||||
@@ -89,6 +91,24 @@ async function main() {
|
|||||||
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
|
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 = [
|
const productDefs = [
|
||||||
{ skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' },
|
{ 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' },
|
{ 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:', {
|
console.log('Seed complete:', {
|
||||||
city: city.name,
|
city: city.name,
|
||||||
|
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
|
||||||
products: products.length,
|
products: products.length,
|
||||||
stores: storeDefs.length,
|
stores: storeDefs.length,
|
||||||
testPhones: {
|
testPhones: {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -83,6 +83,6 @@ import type { ISmsProvider } from './sms/sms.interface';
|
|||||||
TencentLbsProvider,
|
TencentLbsProvider,
|
||||||
{ provide: MAP_PROVIDER, useExisting: 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 {}
|
export class IntegrationsModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -454,3 +454,108 @@ export class UpdateProductDto {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
detailContent?: Record<string, unknown>;
|
detailContent?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -227,6 +227,24 @@ export class AdminProductsQueryDto extends PaginationQueryDto {
|
|||||||
aromaType?: string;
|
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 {
|
export class AdminUserLogsQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { CommonModule } from '../common/common.module';
|
|||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
||||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
|
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
|
||||||
|
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
|
||||||
@@ -53,6 +55,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
|||||||
AdminUserLogsController,
|
AdminUserLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
|
AdminProductDetailTemplatesController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -69,6 +72,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
|||||||
AdminUserLogsService,
|
AdminUserLogsService,
|
||||||
AdminTicketsService,
|
AdminTicketsService,
|
||||||
AdminXiaofeixiaService,
|
AdminXiaofeixiaService,
|
||||||
|
AdminProductDetailTemplatesService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user