This commit is contained in:
魔方熊成祥
2026-05-07 22:54:11 +08:00
parent ba0458abe2
commit bd0163af2d
18 changed files with 885 additions and 125 deletions

BIN
dist.zip Normal file

Binary file not shown.

View File

@@ -1,26 +0,0 @@
import type { BaseResponse } from '@/apis/base/types'
import axiosInstance from '@/apis/base/axiosInstance'
import type { MallCategoryTreeData, MallCategoryTreeParams } from '@/types/mall'
import { compactObject } from '@/utils/mall'
export const mallCategoryTree = async (
payload: MallCategoryTreeParams = {},
signal?: AbortSignal
) => {
const response = await axiosInstance.post<BaseResponse<MallCategoryTreeData>>(
'/api/v1/mall/admin/category_tree',
compactObject(payload),
{ signal }
)
const businessPayload = response.data
if (!businessPayload) {
throw new Error('服务端未返回业务数据')
}
if (businessPayload.code !== 200) {
throw new Error(businessPayload.message || businessPayload.msg || '请求失败')
}
return businessPayload.data
}

View File

@@ -2,5 +2,6 @@ export * from './profile'
export * from './category' export * from './category'
export * from './goods' export * from './goods'
export * from './featuredGoods' export * from './featuredGoods'
export * from './options'
export * from './orders' export * from './orders'
export * from './afterSale' export * from './afterSale'

View File

@@ -0,0 +1,71 @@
import type {
MerchantDeliveryOptionPageData,
MerchantDeliveryOptionSaveParams,
MerchantOptionDeleteData,
MerchantOptionDeleteParams,
MerchantOptionPageParams,
MerchantOptionSaveData,
MerchantPackageOptionPageData,
MerchantPackageOptionSaveParams,
} from '@/types/mall'
import { merchantPost } from './shared'
export const merchantDeliveryOptionPage = (
payload: MerchantOptionPageParams,
signal?: AbortSignal
) =>
merchantPost<MerchantDeliveryOptionPageData, MerchantOptionPageParams>(
'/api/v1/mall/merchant/delivery_option_page',
payload,
signal
)
export const merchantDeliveryOptionSave = (
payload: MerchantDeliveryOptionSaveParams,
signal?: AbortSignal
) =>
merchantPost<MerchantOptionSaveData, MerchantDeliveryOptionSaveParams>(
'/api/v1/mall/merchant/delivery_option_save',
payload,
signal
)
export const merchantDeliveryOptionDelete = (
payload: MerchantOptionDeleteParams,
signal?: AbortSignal
) =>
merchantPost<MerchantOptionDeleteData, MerchantOptionDeleteParams>(
'/api/v1/mall/merchant/delivery_option_delete',
payload,
signal
)
export const merchantPackageOptionPage = (
payload: MerchantOptionPageParams,
signal?: AbortSignal
) =>
merchantPost<MerchantPackageOptionPageData, MerchantOptionPageParams>(
'/api/v1/mall/merchant/package_option_page',
payload,
signal
)
export const merchantPackageOptionSave = (
payload: MerchantPackageOptionSaveParams,
signal?: AbortSignal
) =>
merchantPost<MerchantOptionSaveData, MerchantPackageOptionSaveParams>(
'/api/v1/mall/merchant/package_option_save',
payload,
signal
)
export const merchantPackageOptionDelete = (
payload: MerchantOptionDeleteParams,
signal?: AbortSignal
) =>
merchantPost<MerchantOptionDeleteData, MerchantOptionDeleteParams>(
'/api/v1/mall/merchant/package_option_delete',
payload,
signal
)

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { mallCategoryTree } from '@/apis/mallCategory' import { merchantCategoryTree } from '@/apis/mallMerchant'
import type { MallCategoryItem } from '@/types/mall' import type { MallCategoryItem } from '@/types/mall'
interface MallCategoryTreeNode { interface MallCategoryTreeNode {
@@ -20,7 +20,7 @@ const loadMallCategoryTree = async () => {
} }
if (!pendingCategoryTreeRequest) { if (!pendingCategoryTreeRequest) {
pendingCategoryTreeRequest = mallCategoryTree({ status: 'enabled' }) pendingCategoryTreeRequest = merchantCategoryTree({ status: 'enabled' })
.then(data => { .then(data => {
cachedCategoryTree = data.list ?? [] cachedCategoryTree = data.list ?? []
return cachedCategoryTree return cachedCategoryTree

View File

@@ -618,6 +618,64 @@ a {
white-space: normal; white-space: normal;
} }
.order-detail-product {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.order-detail-product__image {
flex: 0 0 52px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--ant-color-border-secondary);
background: #f7f8fa;
}
.order-detail-product__placeholder {
width: 52px;
height: 52px;
flex: 0 0 52px;
border-radius: 8px;
border: 1px dashed var(--ant-color-border);
background: #fafafa;
color: #a0a6ad;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
.order-detail-product__content {
min-width: 0;
flex: 1;
}
.order-detail-product__title {
display: -webkit-box;
max-height: 36px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 13px !important;
line-height: 18px !important;
white-space: normal;
}
.after-sale-evidence {
display: flex;
align-items: flex-start;
gap: 8px;
}
.after-sale-evidence__image {
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--ant-color-border-secondary);
background: #f7f8fa;
}
.goods-detail-editor { .goods-detail-editor {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { import {
AppstoreOutlined, AppstoreOutlined,
AuditOutlined, AuditOutlined,
InboxOutlined,
LogoutOutlined, LogoutOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
@@ -48,6 +49,11 @@ const NAV_SECTIONS: NavSection[] = [
label: '首页精选商品', label: '首页精选商品',
icon: <StarOutlined />, icon: <StarOutlined />,
}, },
{
key: '/fulfillment-options',
label: '物流包装配置',
icon: <InboxOutlined />,
},
], ],
}, },
{ {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd' import { Button, Card, Form, Image, Input, Select, Space, Table, Tag, Typography } from 'antd'
import { toast } from 'sonner' import { toast } from 'sonner'
import { merchantAfterSalePage } from '@/apis/mallMerchant' import { merchantAfterSalePage } from '@/apis/mallMerchant'
import AuditModal from './AuditModal' import AuditModal from './AuditModal'
@@ -11,7 +11,6 @@ import {
formatUnixTime, formatUnixTime,
getStatusColor, getStatusColor,
getStatusLabel, getStatusLabel,
prettyJsonText,
} from '@/utils/mall' } from '@/utils/mall'
interface AfterSaleSearchValues { interface AfterSaleSearchValues {
@@ -20,6 +19,54 @@ interface AfterSaleSearchValues {
keyword?: string keyword?: string
} }
const isUrlText = (value: string) => /^https?:\/\//i.test(value) || value.startsWith('//')
const normalizeEvidenceUrl = (value: string) => {
const trimmed = value.trim()
if (trimmed.startsWith('//')) {
return `https:${trimmed}`
}
return trimmed
}
const collectEvidenceUrls = (value: unknown, result: string[]) => {
if (typeof value === 'string') {
const normalized = normalizeEvidenceUrl(value)
if (isUrlText(normalized)) {
result.push(normalized)
}
return
}
if (Array.isArray(value)) {
value.forEach(item => collectEvidenceUrls(item, result))
return
}
if (value && typeof value === 'object') {
Object.values(value as Record<string, unknown>).forEach(item => collectEvidenceUrls(item, result))
}
}
const parseEvidenceUrls = (value?: string) => {
if (!value?.trim()) {
return []
}
const result: string[] = []
try {
collectEvidenceUrls(JSON.parse(value) as unknown, result)
} catch {
collectEvidenceUrls(value, result)
}
return Array.from(new Set(result))
}
const isImageEvidence = (url: string) => /\.(png|jpe?g|webp|gif|avif)(?:[?#].*)?$/i.test(url)
export default function AfterSalePage() { export default function AfterSalePage() {
const merchantId = useSessionStore(state => state.merchantId) const merchantId = useSessionStore(state => state.merchantId)
const [form] = Form.useForm<AfterSaleSearchValues>() const [form] = Form.useForm<AfterSaleSearchValues>()
@@ -115,14 +162,15 @@ export default function AfterSalePage() {
<Card className="page-card" title="售后列表"> <Card className="page-card" title="售后列表">
<Table <Table
rowKey="id" rowKey={record => String(record.id)}
loading={loading} loading={loading}
dataSource={list} dataSource={list}
expandable={{ expandable={{
expandedRowRender: record => ( expandedRowRender: record => {
const evidenceUrls = parseEvidenceUrls(record.evidenceJson)
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text> ID{record.id}</Typography.Text>
<Typography.Text> ID{record.uid}</Typography.Text>
<Typography.Text>{record.reason || '-'}</Typography.Text> <Typography.Text>{record.reason || '-'}</Typography.Text>
<Typography.Text>{record.description || '-'}</Typography.Text> <Typography.Text>{record.description || '-'}</Typography.Text>
<Typography.Text>{record.rejectReason || '-'}</Typography.Text> <Typography.Text>{record.rejectReason || '-'}</Typography.Text>
@@ -130,14 +178,34 @@ export default function AfterSalePage() {
<Typography.Text>{formatUnixTime(record.auditTime)}</Typography.Text> <Typography.Text>{formatUnixTime(record.auditTime)}</Typography.Text>
<Typography.Text>{formatUnixTime(record.successTime)}</Typography.Text> <Typography.Text>{formatUnixTime(record.successTime)}</Typography.Text>
<Typography.Text>{formatUnixTime(record.closeTime)}</Typography.Text> <Typography.Text>{formatUnixTime(record.closeTime)}</Typography.Text>
<div> <div className="after-sale-evidence">
JSON <Typography.Text></Typography.Text>
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}> {evidenceUrls.length ? (
{prettyJsonText(record.evidenceJson) || '-'} <Space size={8} wrap>
</Typography.Text> {evidenceUrls.map(url =>
isImageEvidence(url) ? (
<Image
key={url}
src={url}
width={64}
height={64}
className="after-sale-evidence__image"
preview={{ src: url }}
/>
) : (
<Typography.Link key={url} href={url} target="_blank">
</Typography.Link>
)
)}
</Space>
) : (
<Typography.Text type="secondary">-</Typography.Text>
)}
</div> </div>
</Space> </Space>
), )
},
}} }}
pagination={{ pagination={{
current: page, current: page,

View File

@@ -19,8 +19,8 @@ interface GoodsPickerSearchValues {
interface GoodsPickerModalProps { interface GoodsPickerModalProps {
open: boolean open: boolean
merchantId: string merchantId: string
configuredSpuIds: number[] configuredSpuIds: string[]
currentSpuId?: number currentSpuId?: string
onCancel: () => void onCancel: () => void
onSelect: (goods: GoodsSummary) => void onSelect: (goods: GoodsSummary) => void
} }
@@ -138,7 +138,7 @@ export default function GoodsPickerModal({
</Form> </Form>
<Table <Table
rowKey="id" rowKey={record => String(record.id)}
loading={loading} loading={loading}
dataSource={list} dataSource={list}
scroll={{ x: 1080 }} scroll={{ x: 1080 }}
@@ -211,10 +211,10 @@ export default function GoodsPickerModal({
render: (_, record) => ( render: (_, record) => (
<Button <Button
type="link" type="link"
disabled={disabledSpuIds.has(record.id)} disabled={disabledSpuIds.has(String(record.id))}
onClick={() => onSelect(record)} onClick={() => onSelect(record)}
> >
{disabledSpuIds.has(record.id) ? '已配置' : '选择'} {disabledSpuIds.has(String(record.id)) ? '已配置' : '选择'}
</Button> </Button>
), ),
}, },

View File

@@ -37,7 +37,7 @@ interface EditorValues {
} }
interface SelectedGoodsInfo { interface SelectedGoodsInfo {
id: number id: string
spuNo: string spuNo: string
spuName: string spuName: string
subTitle: string subTitle: string
@@ -99,7 +99,7 @@ export default function FeaturedGoodsPage() {
const [editingRecord, setEditingRecord] = useState<MerchantFeaturedGoodsItem | null>(null) const [editingRecord, setEditingRecord] = useState<MerchantFeaturedGoodsItem | null>(null)
const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsInfo | null>(null) const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsInfo | null>(null)
const configuredSpuIds = useMemo(() => list.map(item => item.spuId), [list]) const configuredSpuIds = useMemo(() => list.map(item => String(item.spuId)), [list])
const loadList = async (nextPage = page, nextSize = size) => { const loadList = async (nextPage = page, nextSize = size) => {
if (!merchantId) { if (!merchantId) {
@@ -148,7 +148,7 @@ export default function FeaturedGoodsPage() {
const handleOpenEdit = (record: MerchantFeaturedGoodsItem) => { const handleOpenEdit = (record: MerchantFeaturedGoodsItem) => {
setEditingRecord(record) setEditingRecord(record)
setSelectedGoods({ setSelectedGoods({
id: record.spuId, id: String(record.spuId),
spuNo: record.spuNo, spuNo: record.spuNo,
spuName: record.spuName, spuName: record.spuName,
subTitle: record.subTitle, subTitle: record.subTitle,
@@ -168,7 +168,7 @@ export default function FeaturedGoodsPage() {
try { try {
await merchantFeaturedGoodsDelete({ await merchantFeaturedGoodsDelete({
merchantId, merchantId,
id: record.id, id: String(record.id),
}) })
toast.success('精选商品已删除') toast.success('精选商品已删除')
await loadList() await loadList()
@@ -193,8 +193,8 @@ export default function FeaturedGoodsPage() {
setSaving(true) setSaving(true)
await merchantFeaturedGoodsSave({ await merchantFeaturedGoodsSave({
merchantId, merchantId,
id: editingRecord?.id, id: editingRecord?.id ? String(editingRecord.id) : undefined,
spuId: selectedGoods.id, spuId: String(selectedGoods.id),
sort: values.sort ?? 0, sort: values.sort ?? 0,
status: values.status, status: values.status,
}) })
@@ -447,11 +447,11 @@ export default function FeaturedGoodsPage() {
open={pickerOpen} open={pickerOpen}
merchantId={merchantId} merchantId={merchantId}
configuredSpuIds={configuredSpuIds} configuredSpuIds={configuredSpuIds}
currentSpuId={editingRecord?.spuId} currentSpuId={editingRecord?.spuId ? String(editingRecord.spuId) : undefined}
onCancel={() => setPickerOpen(false)} onCancel={() => setPickerOpen(false)}
onSelect={(goods: GoodsSummary) => { onSelect={(goods: GoodsSummary) => {
setSelectedGoods({ setSelectedGoods({
id: goods.id, id: String(goods.id),
spuNo: goods.spuNo, spuNo: goods.spuNo,
spuName: goods.spuName, spuName: goods.spuName,
subTitle: goods.subTitle, subTitle: goods.subTitle,

View File

@@ -0,0 +1,422 @@
import { useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Space,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { toast } from 'sonner'
import {
merchantDeliveryOptionDelete,
merchantDeliveryOptionPage,
merchantDeliveryOptionSave,
merchantPackageOptionDelete,
merchantPackageOptionPage,
merchantPackageOptionSave,
} from '@/apis/mallMerchant'
import { useSessionStore } from '@/store/session'
import type {
EnableStatus,
MerchantDeliveryOptionItem,
MerchantPackageOptionItem,
} from '@/types/mall'
import { ENABLE_STATUS_OPTIONS, formatUnixTime, getStatusColor, getStatusLabel } from '@/utils/mall'
type OptionKind = 'delivery' | 'package'
type OptionRecord = MerchantDeliveryOptionItem | MerchantPackageOptionItem
interface SearchValues {
status?: EnableStatus | ''
}
interface EditorValues {
optionName: string
optionCode?: string
feeAmount?: number | string
description?: string
sort?: number
status: EnableStatus
}
const KIND_META: Record<
OptionKind,
{
title: string
createText: string
codeLabel: string
codePlaceholder: string
emptyCode: string
}
> = {
delivery: {
title: '物流方式',
createText: '新增物流方式',
codeLabel: '物流编码',
codePlaceholder: '例如 normal / sf',
emptyCode: '未配置物流编码',
},
package: {
title: '包装袋',
createText: '新增包装袋',
codeLabel: '包装编码',
codePlaceholder: '例如 normal_bag / bubble_bag',
emptyCode: '未配置包装编码',
},
}
const getOptionCode = (record: OptionRecord, kind: OptionKind) =>
kind === 'delivery'
? (record as MerchantDeliveryOptionItem).deliveryCode
: (record as MerchantPackageOptionItem).packageCode
const normalizeAmount = (value?: number | string) => {
if (value === undefined || value === null || value === '') {
return '0.00'
}
const amount = Number(value)
if (!Number.isFinite(amount) || amount < 0) {
return '0.00'
}
return amount.toFixed(2)
}
export default function FulfillmentOptionsPage() {
const merchantId = useSessionStore(state => state.merchantId)
const [activeKind, setActiveKind] = useState<OptionKind>('delivery')
const [searchForm] = Form.useForm<SearchValues>()
const [editorForm] = Form.useForm<EditorValues>()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [page, setPage] = useState(1)
const [size, setSize] = useState(20)
const [total, setTotal] = useState(0)
const [deliveryList, setDeliveryList] = useState<MerchantDeliveryOptionItem[]>([])
const [packageList, setPackageList] = useState<MerchantPackageOptionItem[]>([])
const [editingRecord, setEditingRecord] = useState<OptionRecord | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const currentMeta = KIND_META[activeKind]
const currentList = useMemo(
() => (activeKind === 'delivery' ? deliveryList : packageList),
[activeKind, deliveryList, packageList]
)
const loadList = async (kind = activeKind, nextPage = page, nextSize = size) => {
try {
setLoading(true)
const values = searchForm.getFieldsValue()
const payload = {
merchantId: merchantId || undefined,
status: values.status || undefined,
page: nextPage,
size: nextSize,
}
if (kind === 'delivery') {
const result = await merchantDeliveryOptionPage(payload)
setDeliveryList(result.list ?? [])
setTotal(result.total ?? 0)
} else {
const result = await merchantPackageOptionPage(payload)
setPackageList(result.list ?? [])
setTotal(result.total ?? 0)
}
} catch (error) {
const message =
error instanceof Error ? error.message : `加载${KIND_META[kind].title}失败`
toast.error(message)
} finally {
setLoading(false)
}
}
useEffect(() => {
searchForm.setFieldsValue({ status: '' })
setPage(1)
void loadList(activeKind, 1, size)
}, [activeKind, merchantId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleOpenCreate = () => {
setEditingRecord(null)
editorForm.setFieldsValue({
optionName: '',
optionCode: '',
feeAmount: 0,
description: '',
sort: 0,
status: 'enabled',
})
setEditorOpen(true)
}
const handleOpenEdit = (record: OptionRecord) => {
setEditingRecord(record)
editorForm.setFieldsValue({
optionName: record.optionName,
optionCode: getOptionCode(record, activeKind),
feeAmount: Number(record.feeAmount || 0),
description: record.description,
sort: record.sort,
status: record.status,
})
setEditorOpen(true)
}
const handleSave = async () => {
try {
const values = await editorForm.validateFields()
setSaving(true)
const basePayload = {
merchantId: merchantId || undefined,
id: editingRecord?.id,
optionName: values.optionName.trim(),
feeAmount: normalizeAmount(values.feeAmount),
description: values.description?.trim(),
sort: values.sort ?? 0,
status: values.status,
}
if (activeKind === 'delivery') {
await merchantDeliveryOptionSave({
...basePayload,
deliveryCode: values.optionCode?.trim(),
})
} else {
await merchantPackageOptionSave({
...basePayload,
packageCode: values.optionCode?.trim(),
})
}
toast.success(editingRecord ? `${currentMeta.title}已更新` : `${currentMeta.title}已新增`)
setEditorOpen(false)
await loadList()
} catch (error) {
const message = error instanceof Error ? error.message : `保存${currentMeta.title}失败`
toast.error(message)
} finally {
setSaving(false)
}
}
const handleDelete = async (record: OptionRecord) => {
try {
const payload = {
merchantId: merchantId || undefined,
id: record.id,
}
if (activeKind === 'delivery') {
await merchantDeliveryOptionDelete(payload)
} else {
await merchantPackageOptionDelete(payload)
}
toast.success(`${currentMeta.title}已删除`)
await loadList()
} catch (error) {
const message = error instanceof Error ? error.message : `删除${currentMeta.title}失败`
toast.error(message)
}
}
return (
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Card
className="page-card"
title="物流和包装配置"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenCreate}>
{currentMeta.createText}
</Button>
}
>
<Tabs
activeKey={activeKind}
onChange={key => {
setActiveKind(key as OptionKind)
setPage(1)
}}
items={[
{ key: 'delivery', label: '物流方式' },
{ key: 'package', label: '包装袋' },
]}
/>
<Form form={searchForm} layout="inline">
<Form.Item name="status" label="状态">
<Radio.Group
options={ENABLE_STATUS_OPTIONS.map(item => ({ ...item }))}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
onClick={() => {
setPage(1)
void loadList(activeKind, 1, size)
}}
>
</Button>
<Button
onClick={() => {
searchForm.resetFields()
setPage(1)
void loadList(activeKind, 1, size)
}}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card className="page-card" title={`${currentMeta.title}列表`}>
<Table<OptionRecord>
rowKey="id"
loading={loading}
dataSource={currentList}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
onChange: (nextPage, nextSize) => {
setPage(nextPage)
setSize(nextSize)
void loadList(activeKind, nextPage, nextSize)
},
}}
columns={[
{
title: '名称',
dataIndex: 'optionName',
width: 220,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Typography.Text strong>{record.optionName}</Typography.Text>
<Typography.Text type="secondary">
{getOptionCode(record, activeKind) || currentMeta.emptyCode}
</Typography.Text>
</Space>
),
},
{
title: '费用',
dataIndex: 'feeAmount',
width: 120,
render: value => value || '0.00',
},
{
title: '说明',
dataIndex: 'description',
ellipsis: true,
render: value => value || '-',
},
{
title: '排序',
dataIndex: 'sort',
width: 90,
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: value => formatUnixTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_, record) => (
<Space>
<Button type="link" onClick={() => handleOpenEdit(record)}>
</Button>
<Popconfirm
title={`删除${currentMeta.title}`}
description="删除后下单页不再展示该配置,历史订单不受影响。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void handleDelete(record)}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</Card>
<Modal
title={editingRecord ? `编辑${currentMeta.title}` : currentMeta.createText}
open={editorOpen}
confirmLoading={saving}
onOk={() => void handleSave()}
onCancel={() => setEditorOpen(false)}
destroyOnClose
>
<Form form={editorForm} layout="vertical" requiredMark={false}>
<Form.Item
name="optionName"
label="展示名称"
rules={[{ required: true, message: `请输入${currentMeta.title}名称` }]}
>
<Input placeholder={activeKind === 'delivery' ? '例如 普通快递' : '例如 普通包装袋'} />
</Form.Item>
<Form.Item name="optionCode" label={currentMeta.codeLabel}>
<Input placeholder={currentMeta.codePlaceholder} />
</Form.Item>
<Form.Item name="feeAmount" label="费用">
<InputNumber min={0} precision={2} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} placeholder="可填写用户下单时需要看到的说明" />
</Form.Item>
<Space size={16} style={{ width: '100%' }}>
<Form.Item name="sort" label="排序值" style={{ flex: 1 }}>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" style={{ flex: 1 }}>
<Radio.Group
options={[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]}
optionType="button"
buttonStyle="solid"
/>
</Form.Item>
</Space>
</Form>
</Modal>
</Space>
)
}

View File

@@ -41,7 +41,7 @@ import type {
interface GoodsEditorDrawerProps { interface GoodsEditorDrawerProps {
open: boolean open: boolean
merchantId: string merchantId: string
spuId?: number | null spuId?: string | null
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
} }
@@ -94,7 +94,7 @@ interface GoodsVariantSpecFormValue {
} }
interface GoodsSkuFormValue { interface GoodsSkuFormValue {
skuId?: number skuId?: string
skuNo: string skuNo: string
skuName: string skuName: string
barcode?: string barcode?: string
@@ -152,7 +152,7 @@ const emptyVariantSpec = (attribute?: MerchantCategoryAttributeItem): GoodsVaria
}) })
const defaultSku = (): GoodsSkuFormValue => ({ const defaultSku = (): GoodsSkuFormValue => ({
skuId: 0, skuId: '0',
skuNo: '', skuNo: '',
skuName: '', skuName: '',
barcode: '', barcode: '',
@@ -581,7 +581,7 @@ const buildSkusFromVariantSpecs = (
return { return {
...defaultSku(), ...defaultSku(),
...previousSku, ...previousSku,
skuId: matchedSku?.skuId ?? 0, skuId: matchedSku?.skuId ?? '0',
skuName: previousSku?.skuName?.trim() || specName || '默认规格', skuName: previousSku?.skuName?.trim() || specName || '默认规格',
specs, specs,
} }
@@ -989,7 +989,7 @@ export default function GoodsEditorDrawer({
setLoading(true) setLoading(true)
const result = await merchantGoodsDetail({ const result = await merchantGoodsDetail({
merchantId, merchantId,
spuId, spuId: String(spuId),
}) })
if (cancelled) { if (cancelled) {
@@ -1005,7 +1005,7 @@ export default function GoodsEditorDrawer({
const extraInfo = parseExtraInfo(data.extraInfoJson) const extraInfo = parseExtraInfo(data.extraInfoJson)
const skus = data.skus.length const skus = data.skus.length
? data.skus.map(sku => ({ ? data.skus.map(sku => ({
skuId: sku.id, skuId: String(sku.id || '0'),
skuNo: sku.skuNo, skuNo: sku.skuNo,
skuName: sku.skuName, skuName: sku.skuName,
barcode: sku.barcode, barcode: sku.barcode,
@@ -1083,7 +1083,7 @@ export default function GoodsEditorDrawer({
await merchantGoodsSave({ await merchantGoodsSave({
merchantId, merchantId,
spuId: spuId ?? 0, spuId: spuId ? String(spuId) : '0',
categoryId: Number(values.categoryId), categoryId: Number(values.categoryId),
spuName: values.spuName.trim(), spuName: values.spuName.trim(),
subTitle: values.subTitle?.trim() || undefined, subTitle: values.subTitle?.trim() || undefined,
@@ -1114,7 +1114,7 @@ export default function GoodsEditorDrawer({
} }
return { return {
skuId: Number(sku.skuId ?? 0), skuId: sku.skuId ? String(sku.skuId) : '0',
skuNo: sku.skuNo?.trim() ?? '', skuNo: sku.skuNo?.trim() ?? '',
skuName: sku.skuName?.trim() || specs.map(spec => spec.valueName).join('-') || '默认规格', skuName: sku.skuName?.trim() || specs.map(spec => spec.valueName).join('-') || '默认规格',
barcode: sku.barcode?.trim() ?? '', barcode: sku.barcode?.trim() ?? '',
@@ -1423,7 +1423,7 @@ export default function GoodsEditorDrawer({
<tr key={field.key}> <tr key={field.key}>
<td> <td>
<Form.Item name={[field.name, 'skuId']} hidden> <Form.Item name={[field.name, 'skuId']} hidden>
<InputNumber /> <Input />
</Form.Item> </Form.Item>
<Form.Item name={[field.name, 'skuName']} hidden> <Form.Item name={[field.name, 'skuName']} hidden>
<Input /> <Input />

View File

@@ -90,7 +90,7 @@ export default function GoodsPage() {
try { try {
await merchantGoodsPublish({ await merchantGoodsPublish({
merchantId: merchantId ?? '', merchantId: merchantId ?? '',
spuId: record.id, spuId: String(record.id),
saleStatus: record.saleStatus === 'on' ? 'off' : 'on', saleStatus: record.saleStatus === 'on' ? 'off' : 'on',
}) })
toast.success(`商品已${record.saleStatus === 'on' ? '下架' : '上架'}`) toast.success(`商品已${record.saleStatus === 'on' ? '下架' : '上架'}`)
@@ -186,7 +186,7 @@ export default function GoodsPage() {
<Card className="page-card" title="商品列表"> <Card className="page-card" title="商品列表">
<Table <Table
rowKey="id" rowKey={record => String(record.id)}
loading={loading} loading={loading}
dataSource={list} dataSource={list}
pagination={{ pagination={{
@@ -313,7 +313,7 @@ export default function GoodsPage() {
<GoodsEditorDrawer <GoodsEditorDrawer
open={drawerOpen} open={drawerOpen}
merchantId={merchantId ?? ''} merchantId={merchantId ?? ''}
spuId={editingRecord?.id} spuId={editingRecord?.id ? String(editingRecord.id) : undefined}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
onSuccess={() => void loadList()} onSuccess={() => void loadList()}
/> />

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Descriptions, Drawer, Space, Spin, Table, Tag, Typography } from 'antd' import { Card, Descriptions, Drawer, Image, Space, Spin, Table, Tag, Typography } from 'antd'
import { toast } from 'sonner' import { toast } from 'sonner'
import { merchantOrderDetail } from '@/apis/mallMerchant' import { merchantOrderDetail } from '@/apis/mallMerchant'
import type { OrderDetailInfo } from '@/types/mall' import type { OrderDetailInfo } from '@/types/mall'
import { formatUnixTime, getStatusColor, getStatusLabel, prettyJsonText } from '@/utils/mall' import { formatUnixTime, getStatusColor, getStatusLabel } from '@/utils/mall'
interface OrderDetailDrawerProps { interface OrderDetailDrawerProps {
open: boolean open: boolean
@@ -13,6 +13,49 @@ interface OrderDetailDrawerProps {
onClose: () => void onClose: () => void
} }
const normalizeSpecText = (value: unknown) => {
if (typeof value === 'string') {
return value.trim()
}
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
const parseSkuSpecs = (value?: string) => {
if (!value?.trim()) {
return []
}
try {
const parsed = JSON.parse(value) as unknown
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map(item => {
const record = item as Record<string, unknown>
const attributeName =
normalizeSpecText(record.attributeName) || normalizeSpecText(record.name)
const valueName =
normalizeSpecText(record.valueName) || normalizeSpecText(record.value)
if (!attributeName && !valueName) {
return null
}
return { attributeName, valueName }
})
.filter((item): item is { attributeName: string; valueName: string } => Boolean(item))
} catch {
return []
}
}
export default function OrderDetailDrawer({ export default function OrderDetailDrawer({
open, open,
merchantId, merchantId,
@@ -56,8 +99,10 @@ export default function OrderDetailDrawer({
} }
}, [merchantId, open, orderId, refreshToken]) }, [merchantId, open, orderId, refreshToken])
const drawerTitle = data?.orderNo ? `订单详情 ${data.orderNo}` : '订单详情'
return ( return (
<Drawer title={orderId ? `订单详情 #${orderId}` : '订单详情'} width={920} open={open} onClose={onClose}> <Drawer title={drawerTitle} width={1080} open={open} onClose={onClose}>
<Spin spinning={loading}> <Spin spinning={loading}>
{!data ? null : ( {!data ? null : (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space direction="vertical" size={16} style={{ width: '100%' }}>
@@ -65,9 +110,6 @@ export default function OrderDetailDrawer({
<Descriptions column={2} size="small"> <Descriptions column={2} size="small">
<Descriptions.Item label="订单号">{data.orderNo}</Descriptions.Item> <Descriptions.Item label="订单号">{data.orderNo}</Descriptions.Item>
<Descriptions.Item label="交易号">{data.tradeNo}</Descriptions.Item> <Descriptions.Item label="交易号">{data.tradeNo}</Descriptions.Item>
<Descriptions.Item label="订单 ID">{data.id}</Descriptions.Item>
<Descriptions.Item label="会员 ID">{data.uid}</Descriptions.Item>
<Descriptions.Item label="商家 ID">{data.merchantId}</Descriptions.Item>
<Descriptions.Item label="商家名称">{data.merchantName || '-'}</Descriptions.Item> <Descriptions.Item label="商家名称">{data.merchantName || '-'}</Descriptions.Item>
<Descriptions.Item label="订单状态"> <Descriptions.Item label="订单状态">
<Tag color={getStatusColor(data.orderStatus)}>{getStatusLabel(data.orderStatus)}</Tag> <Tag color={getStatusColor(data.orderStatus)}>{getStatusLabel(data.orderStatus)}</Tag>
@@ -90,11 +132,6 @@ export default function OrderDetailDrawer({
<Descriptions.Item label="支付渠道">{data.payChannel || '-'}</Descriptions.Item> <Descriptions.Item label="支付渠道">{data.payChannel || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatUnixTime(data.createdAt)}</Descriptions.Item> <Descriptions.Item label="创建时间">{formatUnixTime(data.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatUnixTime(data.updatedAt)}</Descriptions.Item> <Descriptions.Item label="更新时间">{formatUnixTime(data.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="扩展 JSON" span={2}>
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
{prettyJsonText(data.extraInfoJson) || '-'}
</Typography.Text>
</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
@@ -125,29 +162,66 @@ export default function OrderDetailDrawer({
<Card className="page-card" title="商品明细"> <Card className="page-card" title="商品明细">
<Table <Table
rowKey="id" rowKey={record => String(record.id)}
pagination={false} pagination={false}
scroll={{ x: 960 }}
dataSource={data.items} dataSource={data.items}
columns={[ columns={[
{ {
title: '商品', title: '商品',
dataIndex: 'spuName', dataIndex: 'spuName',
width: 280,
render: (_, record) => ( render: (_, record) => (
<Space direction="vertical" size={2}> <div className="order-detail-product">
<Typography.Text strong>{record.spuName}</Typography.Text> {record.coverUrl ? (
<Typography.Text type="secondary">{record.skuName}</Typography.Text> <Image
src={record.coverUrl}
alt={record.spuName}
width={52}
height={52}
className="order-detail-product__image"
preview={{ src: record.coverUrl }}
/>
) : (
<div className="order-detail-product__placeholder"></div>
)}
<Space direction="vertical" size={2} className="order-detail-product__content">
<Typography.Text
strong
className="order-detail-product__title"
title={record.spuName}
>
{record.spuName}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: record.skuName }}>
{record.skuName || '-'}
</Typography.Text>
</Space> </Space>
</div>
), ),
}, },
{ {
title: '规格 JSON', title: '规格',
dataIndex: 'skuSpecJson', dataIndex: 'skuSpecJson',
width: 260, width: 220,
render: value => ( render: (_, record) => {
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}> const specs = parseSkuSpecs(record.skuSpecJson)
{prettyJsonText(value) || '-'}
</Typography.Text> if (!specs.length) {
), return record.skuName || '-'
}
return (
<Space size={[4, 4]} wrap>
{specs.map(spec => (
<Tag key={`${spec.attributeName}-${spec.valueName}`}>
{spec.attributeName ? `${spec.attributeName}: ` : ''}
{spec.valueName}
</Tag>
))}
</Space>
)
},
}, },
{ {
title: '数量', title: '数量',

View File

@@ -8,6 +8,7 @@ import LoginPage from '@/pages/login'
import ProfilePage from '@/pages/profile' import ProfilePage from '@/pages/profile'
import GoodsPage from '@/pages/goods' import GoodsPage from '@/pages/goods'
import FeaturedGoodsPage from '@/pages/featuredGoods' import FeaturedGoodsPage from '@/pages/featuredGoods'
import FulfillmentOptionsPage from '@/pages/fulfillmentOptions'
import OrdersPage from '@/pages/orders' import OrdersPage from '@/pages/orders'
import AfterSalePage from '@/pages/afterSale' import AfterSalePage from '@/pages/afterSale'
@@ -44,6 +45,10 @@ export const routeConfig: RouteObject[] = [
path: 'featured-goods', path: 'featured-goods',
element: <FeaturedGoodsPage />, element: <FeaturedGoodsPage />,
}, },
{
path: 'fulfillment-options',
element: <FulfillmentOptionsPage />,
},
{ {
path: 'orders', path: 'orders',
element: <OrdersPage />, element: <OrdersPage />,

View File

@@ -22,7 +22,7 @@ export type AfterSaleStatus =
| 'closed' | 'closed'
export interface MerchantInfo { export interface MerchantInfo {
id: string | number id: string
uid: number uid: number
merchantNo: string merchantNo: string
merchantName: string merchantName: string
@@ -53,8 +53,8 @@ export interface MerchantInfo {
} }
export interface GoodsSkuInfo { export interface GoodsSkuInfo {
id: number id: string
spuId: number spuId: string
merchantId: string merchantId: string
skuNo: string skuNo: string
skuName: string skuName: string
@@ -74,7 +74,7 @@ export interface GoodsSkuInfo {
} }
export interface GoodsSummary { export interface GoodsSummary {
id: number id: string
merchantId: string merchantId: string
categoryId: number categoryId: number
spuNo: string spuNo: string
@@ -98,7 +98,7 @@ export interface GoodsSummary {
} }
export interface GoodsDetailInfo { export interface GoodsDetailInfo {
id: number id: string
merchantId: string merchantId: string
categoryId: number categoryId: number
spuNo: string spuNo: string
@@ -197,8 +197,8 @@ export interface OrderItemInfo {
orderId: number orderId: number
tradeId: number tradeId: number
orderNo: string orderNo: string
spuId: number spuId: string
skuId: number skuId: string
spuName: string spuName: string
skuName: string skuName: string
coverUrl: string coverUrl: string
@@ -303,7 +303,7 @@ export interface AfterSaleSummary {
} }
export interface MerchantGoodsSkuInput { export interface MerchantGoodsSkuInput {
skuId: number skuId: string
skuNo: string skuNo: string
skuName: string skuName: string
barcode: string barcode: string
@@ -361,7 +361,7 @@ export interface MerchantGoodsPageData {
export interface MerchantGoodsDetailParams { export interface MerchantGoodsDetailParams {
merchantId: string merchantId: string
spuId: number spuId: string
} }
export interface MerchantGoodsDetailData { export interface MerchantGoodsDetailData {
@@ -370,7 +370,7 @@ export interface MerchantGoodsDetailData {
export interface MerchantGoodsSaveParams { export interface MerchantGoodsSaveParams {
merchantId: string merchantId: string
spuId?: number spuId?: string
categoryId: number categoryId: number
spuName: string spuName: string
subTitle?: string subTitle?: string
@@ -387,24 +387,24 @@ export interface MerchantGoodsSaveParams {
} }
export interface MerchantGoodsSaveData { export interface MerchantGoodsSaveData {
spuId: number spuId: string
skuIds: number[] skuIds: string[]
} }
export interface MerchantGoodsPublishParams { export interface MerchantGoodsPublishParams {
merchantId: string merchantId: string
spuId: number spuId: string
saleStatus: SaleStatus saleStatus: SaleStatus
} }
export interface MerchantGoodsPublishData { export interface MerchantGoodsPublishData {
spuId: number spuId: string
saleStatus: SaleStatus saleStatus: SaleStatus
} }
export interface MerchantFeaturedGoodsItem { export interface MerchantFeaturedGoodsItem {
id: number id: string
spuId: number spuId: string
spuNo: string spuNo: string
spuName: string spuName: string
subTitle: string subTitle: string
@@ -434,23 +434,99 @@ export interface MerchantFeaturedGoodsPageData {
export interface MerchantFeaturedGoodsSaveParams { export interface MerchantFeaturedGoodsSaveParams {
merchantId: string merchantId: string
id?: number id?: string
spuId: number spuId: string
sort?: number sort?: number
status?: EnableStatus status?: EnableStatus
} }
export interface MerchantFeaturedGoodsSaveData { export interface MerchantFeaturedGoodsSaveData {
id: number id: string
spuId: number spuId: string
} }
export interface MerchantFeaturedGoodsDeleteParams { export interface MerchantFeaturedGoodsDeleteParams {
merchantId: string merchantId: string
id: number id: string
} }
export interface MerchantFeaturedGoodsDeleteData { export interface MerchantFeaturedGoodsDeleteData {
id: string
}
export interface MerchantDeliveryOptionItem {
id: number
merchantId: string
optionName: string
deliveryCode: string
feeAmount: string
description: string
sort: number
status: EnableStatus
createdAt?: number
updatedAt?: number
}
export interface MerchantPackageOptionItem {
id: number
merchantId: string
optionName: string
packageCode: string
feeAmount: string
description: string
sort: number
status: EnableStatus
createdAt?: number
updatedAt?: number
}
export interface MerchantOptionPageParams extends PageRequest {
merchantId?: string
status?: EnableStatus
}
export interface MerchantDeliveryOptionPageData {
total: number
list: MerchantDeliveryOptionItem[]
}
export interface MerchantPackageOptionPageData {
total: number
list: MerchantPackageOptionItem[]
}
export interface MerchantDeliveryOptionSaveParams {
merchantId?: string
id?: number
optionName: string
deliveryCode?: string
feeAmount?: string
description?: string
sort?: number
status?: EnableStatus
}
export interface MerchantPackageOptionSaveParams {
merchantId?: string
id?: number
optionName: string
packageCode?: string
feeAmount?: string
description?: string
sort?: number
status?: EnableStatus
}
export interface MerchantOptionSaveData {
id: number
}
export interface MerchantOptionDeleteParams {
merchantId?: string
id: number
}
export interface MerchantOptionDeleteData {
id: number id: number
} }

View File

@@ -262,7 +262,7 @@ const mapHqbSku = (
const salePrice = toMoneyText(sku.price ?? goods.price ?? goods.yprice ?? goods.maxPrice) const salePrice = toMoneyText(sku.price ?? goods.price ?? goods.yprice ?? goods.maxPrice)
return { return {
skuId: 0, skuId: '0',
skuNo: normalizeText(sku.skuCode || sku.id), skuNo: normalizeText(sku.skuCode || sku.id),
skuName: buildSkuName(title, specs, sku.keyName), skuName: buildSkuName(title, specs, sku.keyName),
barcode: '', barcode: '',
@@ -279,7 +279,7 @@ const mapHqbSku = (
} }
const buildDefaultSku = (goods: HqbGoodsInfo, title: string): MerchantGoodsSkuInput => ({ const buildDefaultSku = (goods: HqbGoodsInfo, title: string): MerchantGoodsSkuInput => ({
skuId: 0, skuId: '0',
skuNo: normalizeText(goods.goodsSn || goods.id), skuNo: normalizeText(goods.goodsSn || goods.id),
skuName: '默认 SKU', skuName: '默认 SKU',
barcode: '', barcode: '',
@@ -326,7 +326,7 @@ export const buildMallGoodsFromHqb = (
const payload: MerchantGoodsSaveParams = { const payload: MerchantGoodsSaveParams = {
merchantId, merchantId,
spuId: 0, spuId: '0',
categoryId: HQB_TARGET_CATEGORY_ID, categoryId: HQB_TARGET_CATEGORY_ID,
spuName: title, spuName: title,
subTitle, subTitle,

View File

@@ -102,12 +102,17 @@ export const getStatusLabel = (value?: string) => {
export const getStatusColor = (value?: string) => STATUS_COLOR_MAP[value ?? ''] ?? 'default' export const getStatusColor = (value?: string) => STATUS_COLOR_MAP[value ?? ''] ?? 'default'
export const formatUnixTime = (value?: number) => { export const formatUnixTime = (value?: number | string | null) => {
if (!value) { if (value === undefined || value === null) {
return '-' return '-'
} }
return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss') const timestamp = Number(typeof value === 'string' ? value.trim() : value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '-'
}
return dayjs.unix(timestamp).format('YYYY-MM-DD HH:mm:ss')
} }
export const prettyJsonText = (value?: string) => { export const prettyJsonText = (value?: string) => {