短信验证调试成功
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
|
||||
Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
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';
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
@@ -21,9 +29,196 @@ type Row = {
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProductFormValues = {
|
||||
skuCode?: string;
|
||||
barcode69?: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
aromaType?: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
return {
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
|
||||
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
|
||||
storyTitle: detail.storyTitle ?? '',
|
||||
storyText: detail.storyText ?? '',
|
||||
features: detail.features?.length
|
||||
? detail.features
|
||||
: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildProductPayload(v: ProductFormValues) {
|
||||
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
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() || '',
|
||||
}));
|
||||
|
||||
const detailContent: ProductDetailContentDto = {
|
||||
storyTitle: v.storyTitle?.trim() || undefined,
|
||||
storyText: v.storyText?.trim() || undefined,
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
skuCode: v.skuCode,
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
subtitle: v.subtitle,
|
||||
aromaType: v.aromaType,
|
||||
spec: v.spec,
|
||||
price: v.price,
|
||||
benefitAmount: v.benefitAmount,
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
detailContent,
|
||||
};
|
||||
}
|
||||
|
||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||
return (
|
||||
<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>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetailFields() {
|
||||
return (
|
||||
<>
|
||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情长图(DETAIL)</Typography.Text>
|
||||
<ImageUrlList name="detailImageUrls" label="详情图" bizType="DETAIL" />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
|
||||
<Input placeholder="500ml | 52度" />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
@@ -60,10 +255,7 @@ export default function ProductsPage() {
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
});
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
@@ -88,11 +280,12 @@ export default function ProductsPage() {
|
||||
</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={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const payload = buildProductPayload(v);
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
@@ -106,48 +299,32 @@ export default function ProductsPage() {
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||
]} />
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(v) });
|
||||
const payload = buildProductPayload(v);
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}} width={520}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
|
||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input placeholder="500ml | 52度" /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||
]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user