diff --git a/dist.zip b/dist.zip new file mode 100644 index 0000000..439872a Binary files /dev/null and b/dist.zip differ diff --git a/src/apis/mallCategory.ts b/src/apis/mallCategory.ts deleted file mode 100644 index ed9ed96..0000000 --- a/src/apis/mallCategory.ts +++ /dev/null @@ -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>( - '/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 -} diff --git a/src/apis/mallMerchant/index.ts b/src/apis/mallMerchant/index.ts index 363d9a1..60649fc 100644 --- a/src/apis/mallMerchant/index.ts +++ b/src/apis/mallMerchant/index.ts @@ -2,5 +2,6 @@ export * from './profile' export * from './category' export * from './goods' export * from './featuredGoods' +export * from './options' export * from './orders' export * from './afterSale' diff --git a/src/apis/mallMerchant/options.ts b/src/apis/mallMerchant/options.ts new file mode 100644 index 0000000..4b376ce --- /dev/null +++ b/src/apis/mallMerchant/options.ts @@ -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( + '/api/v1/mall/merchant/delivery_option_page', + payload, + signal + ) + +export const merchantDeliveryOptionSave = ( + payload: MerchantDeliveryOptionSaveParams, + signal?: AbortSignal +) => + merchantPost( + '/api/v1/mall/merchant/delivery_option_save', + payload, + signal + ) + +export const merchantDeliveryOptionDelete = ( + payload: MerchantOptionDeleteParams, + signal?: AbortSignal +) => + merchantPost( + '/api/v1/mall/merchant/delivery_option_delete', + payload, + signal + ) + +export const merchantPackageOptionPage = ( + payload: MerchantOptionPageParams, + signal?: AbortSignal +) => + merchantPost( + '/api/v1/mall/merchant/package_option_page', + payload, + signal + ) + +export const merchantPackageOptionSave = ( + payload: MerchantPackageOptionSaveParams, + signal?: AbortSignal +) => + merchantPost( + '/api/v1/mall/merchant/package_option_save', + payload, + signal + ) + +export const merchantPackageOptionDelete = ( + payload: MerchantOptionDeleteParams, + signal?: AbortSignal +) => + merchantPost( + '/api/v1/mall/merchant/package_option_delete', + payload, + signal + ) diff --git a/src/hooks/useMallCategoryTree.ts b/src/hooks/useMallCategoryTree.ts index 2ba9a6d..b935c67 100644 --- a/src/hooks/useMallCategoryTree.ts +++ b/src/hooks/useMallCategoryTree.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' -import { mallCategoryTree } from '@/apis/mallCategory' +import { merchantCategoryTree } from '@/apis/mallMerchant' import type { MallCategoryItem } from '@/types/mall' interface MallCategoryTreeNode { @@ -20,7 +20,7 @@ const loadMallCategoryTree = async () => { } if (!pendingCategoryTreeRequest) { - pendingCategoryTreeRequest = mallCategoryTree({ status: 'enabled' }) + pendingCategoryTreeRequest = merchantCategoryTree({ status: 'enabled' }) .then(data => { cachedCategoryTree = data.list ?? [] return cachedCategoryTree diff --git a/src/index.css b/src/index.css index 3148f35..74cf60e 100644 --- a/src/index.css +++ b/src/index.css @@ -618,6 +618,64 @@ a { 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 { display: flex; align-items: flex-start; diff --git a/src/layouts/main/MainLayout.tsx b/src/layouts/main/MainLayout.tsx index d1b9d5a..4781d19 100644 --- a/src/layouts/main/MainLayout.tsx +++ b/src/layouts/main/MainLayout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { AppstoreOutlined, AuditOutlined, + InboxOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, @@ -48,6 +49,11 @@ const NAV_SECTIONS: NavSection[] = [ label: '首页精选商品', icon: , }, + { + key: '/fulfillment-options', + label: '物流包装配置', + icon: , + }, ], }, { diff --git a/src/pages/afterSale/index.tsx b/src/pages/afterSale/index.tsx index 24af9e1..eaedf27 100644 --- a/src/pages/afterSale/index.tsx +++ b/src/pages/afterSale/index.tsx @@ -1,5 +1,5 @@ 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 { merchantAfterSalePage } from '@/apis/mallMerchant' import AuditModal from './AuditModal' @@ -11,7 +11,6 @@ import { formatUnixTime, getStatusColor, getStatusLabel, - prettyJsonText, } from '@/utils/mall' interface AfterSaleSearchValues { @@ -20,6 +19,54 @@ interface AfterSaleSearchValues { 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).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() { const merchantId = useSessionStore(state => state.merchantId) const [form] = Form.useForm() @@ -115,29 +162,50 @@ export default function AfterSalePage() { String(record.id)} loading={loading} dataSource={list} expandable={{ - expandedRowRender: record => ( - - 售后单 ID:{record.id} - 会员 ID:{record.uid} - 原因:{record.reason || '-'} - 说明:{record.description || '-'} - 驳回原因:{record.rejectReason || '-'} - 处理备注:{record.handleRemark || '-'} - 审核时间:{formatUnixTime(record.auditTime)} - 完成时间:{formatUnixTime(record.successTime)} - 关闭时间:{formatUnixTime(record.closeTime)} -
- 凭证 JSON: - - {prettyJsonText(record.evidenceJson) || '-'} - -
-
- ), + expandedRowRender: record => { + const evidenceUrls = parseEvidenceUrls(record.evidenceJson) + + return ( + + 原因:{record.reason || '-'} + 说明:{record.description || '-'} + 驳回原因:{record.rejectReason || '-'} + 处理备注:{record.handleRemark || '-'} + 审核时间:{formatUnixTime(record.auditTime)} + 完成时间:{formatUnixTime(record.successTime)} + 关闭时间:{formatUnixTime(record.closeTime)} +
+ 凭证: + {evidenceUrls.length ? ( + + {evidenceUrls.map(url => + isImageEvidence(url) ? ( + + ) : ( + + 查看凭证 + + ) + )} + + ) : ( + - + )} +
+
+ ) + }, }} pagination={{ current: page, diff --git a/src/pages/featuredGoods/GoodsPickerModal.tsx b/src/pages/featuredGoods/GoodsPickerModal.tsx index b5970b7..49ca561 100644 --- a/src/pages/featuredGoods/GoodsPickerModal.tsx +++ b/src/pages/featuredGoods/GoodsPickerModal.tsx @@ -19,8 +19,8 @@ interface GoodsPickerSearchValues { interface GoodsPickerModalProps { open: boolean merchantId: string - configuredSpuIds: number[] - currentSpuId?: number + configuredSpuIds: string[] + currentSpuId?: string onCancel: () => void onSelect: (goods: GoodsSummary) => void } @@ -138,7 +138,7 @@ export default function GoodsPickerModal({
String(record.id)} loading={loading} dataSource={list} scroll={{ x: 1080 }} @@ -211,10 +211,10 @@ export default function GoodsPickerModal({ render: (_, record) => ( ), }, diff --git a/src/pages/featuredGoods/index.tsx b/src/pages/featuredGoods/index.tsx index 801f9d1..f855367 100644 --- a/src/pages/featuredGoods/index.tsx +++ b/src/pages/featuredGoods/index.tsx @@ -37,7 +37,7 @@ interface EditorValues { } interface SelectedGoodsInfo { - id: number + id: string spuNo: string spuName: string subTitle: string @@ -99,7 +99,7 @@ export default function FeaturedGoodsPage() { const [editingRecord, setEditingRecord] = useState(null) const [selectedGoods, setSelectedGoods] = useState(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) => { if (!merchantId) { @@ -148,7 +148,7 @@ export default function FeaturedGoodsPage() { const handleOpenEdit = (record: MerchantFeaturedGoodsItem) => { setEditingRecord(record) setSelectedGoods({ - id: record.spuId, + id: String(record.spuId), spuNo: record.spuNo, spuName: record.spuName, subTitle: record.subTitle, @@ -168,7 +168,7 @@ export default function FeaturedGoodsPage() { try { await merchantFeaturedGoodsDelete({ merchantId, - id: record.id, + id: String(record.id), }) toast.success('精选商品已删除') await loadList() @@ -193,8 +193,8 @@ export default function FeaturedGoodsPage() { setSaving(true) await merchantFeaturedGoodsSave({ merchantId, - id: editingRecord?.id, - spuId: selectedGoods.id, + id: editingRecord?.id ? String(editingRecord.id) : undefined, + spuId: String(selectedGoods.id), sort: values.sort ?? 0, status: values.status, }) @@ -447,11 +447,11 @@ export default function FeaturedGoodsPage() { open={pickerOpen} merchantId={merchantId} configuredSpuIds={configuredSpuIds} - currentSpuId={editingRecord?.spuId} + currentSpuId={editingRecord?.spuId ? String(editingRecord.spuId) : undefined} onCancel={() => setPickerOpen(false)} onSelect={(goods: GoodsSummary) => { setSelectedGoods({ - id: goods.id, + id: String(goods.id), spuNo: goods.spuNo, spuName: goods.spuName, subTitle: goods.subTitle, diff --git a/src/pages/fulfillmentOptions/index.tsx b/src/pages/fulfillmentOptions/index.tsx new file mode 100644 index 0000000..e976cc4 --- /dev/null +++ b/src/pages/fulfillmentOptions/index.tsx @@ -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('delivery') + const [searchForm] = Form.useForm() + const [editorForm] = Form.useForm() + 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([]) + const [packageList, setPackageList] = useState([]) + const [editingRecord, setEditingRecord] = useState(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 ( + + } onClick={handleOpenCreate}> + {currentMeta.createText} + + } + > + { + setActiveKind(key as OptionKind) + setPage(1) + }} + items={[ + { key: 'delivery', label: '物流方式' }, + { key: 'package', label: '包装袋' }, + ]} + /> + +
+ + ({ ...item }))} + optionType="button" + buttonStyle="solid" + /> + + + + + + + + +
+ + + + 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) => ( + + {record.optionName} + + {getOptionCode(record, activeKind) || currentMeta.emptyCode} + + + ), + }, + { + 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 => {getStatusLabel(value)}, + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: value => formatUnixTime(value), + }, + { + title: '操作', + key: 'actions', + width: 150, + render: (_, record) => ( + + + void handleDelete(record)} + > + + + + ), + }, + ]} + /> + + + void handleSave()} + onCancel={() => setEditorOpen(false)} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/src/pages/goods/GoodsEditorDrawer.tsx b/src/pages/goods/GoodsEditorDrawer.tsx index eca8149..fb291b1 100644 --- a/src/pages/goods/GoodsEditorDrawer.tsx +++ b/src/pages/goods/GoodsEditorDrawer.tsx @@ -41,7 +41,7 @@ import type { interface GoodsEditorDrawerProps { open: boolean merchantId: string - spuId?: number | null + spuId?: string | null onClose: () => void onSuccess: () => void } @@ -94,7 +94,7 @@ interface GoodsVariantSpecFormValue { } interface GoodsSkuFormValue { - skuId?: number + skuId?: string skuNo: string skuName: string barcode?: string @@ -152,7 +152,7 @@ const emptyVariantSpec = (attribute?: MerchantCategoryAttributeItem): GoodsVaria }) const defaultSku = (): GoodsSkuFormValue => ({ - skuId: 0, + skuId: '0', skuNo: '', skuName: '', barcode: '', @@ -581,7 +581,7 @@ const buildSkusFromVariantSpecs = ( return { ...defaultSku(), ...previousSku, - skuId: matchedSku?.skuId ?? 0, + skuId: matchedSku?.skuId ?? '0', skuName: previousSku?.skuName?.trim() || specName || '默认规格', specs, } @@ -989,7 +989,7 @@ export default function GoodsEditorDrawer({ setLoading(true) const result = await merchantGoodsDetail({ merchantId, - spuId, + spuId: String(spuId), }) if (cancelled) { @@ -1005,7 +1005,7 @@ export default function GoodsEditorDrawer({ const extraInfo = parseExtraInfo(data.extraInfoJson) const skus = data.skus.length ? data.skus.map(sku => ({ - skuId: sku.id, + skuId: String(sku.id || '0'), skuNo: sku.skuNo, skuName: sku.skuName, barcode: sku.barcode, @@ -1083,7 +1083,7 @@ export default function GoodsEditorDrawer({ await merchantGoodsSave({ merchantId, - spuId: spuId ?? 0, + spuId: spuId ? String(spuId) : '0', categoryId: Number(values.categoryId), spuName: values.spuName.trim(), subTitle: values.subTitle?.trim() || undefined, @@ -1114,7 +1114,7 @@ export default function GoodsEditorDrawer({ } return { - skuId: Number(sku.skuId ?? 0), + skuId: sku.skuId ? String(sku.skuId) : '0', skuNo: sku.skuNo?.trim() ?? '', skuName: sku.skuName?.trim() || specs.map(spec => spec.valueName).join('-') || '默认规格', barcode: sku.barcode?.trim() ?? '', @@ -1423,7 +1423,7 @@ export default function GoodsEditorDrawer({