From 792b543ba88acda4186bd823461ef1bb76d12f5b Mon Sep 17 00:00:00 2001 From: ljy Date: Sun, 19 Jul 2026 22:16:55 +0800 Subject: [PATCH] =?UTF-8?q?fix;=E6=8F=90=E4=BA=A4=E3=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 1 + apps/admin-web/src/pages/InvoicesPage.tsx | 247 +++++++++++ apps/admin-web/src/pages/TicketsPage.tsx | 130 ++++-- apps/h5-user/.env.example | 2 + apps/h5-user/src/App.tsx | 8 + .../src/components/ContactCustomerSheet.tsx | 19 +- apps/h5-user/src/lib/customer-service.ts | 23 + apps/h5-user/src/lib/upload.ts | 37 ++ apps/h5-user/src/pages/AfterSaleListPage.tsx | 71 ++++ apps/h5-user/src/pages/AfterSalePage.tsx | 232 ++++++++++ .../h5-user/src/pages/CustomerServicePage.tsx | 191 +-------- apps/h5-user/src/pages/InvoiceApplyPage.tsx | 158 +++++++ apps/h5-user/src/pages/InvoiceListPage.tsx | 68 +++ apps/h5-user/src/pages/MinePage.tsx | 2 + apps/h5-user/src/pages/OrderDetailPage.tsx | 34 +- apps/h5-user/src/styles.css | 352 +++++++++++----- packages/shared-types/src/config.ts | 7 + packages/shared-types/src/hq-permissions.ts | 6 +- packages/shared-types/src/index.ts | 2 + packages/shared-types/src/invoice.ts | 54 +++ packages/shared-types/src/ticket.ts | 33 +- server/dukang-api/prisma/schema.prisma | 51 +++ .../modules/common/dto/common-mutate.dto.ts | 5 +- .../src/modules/common/ticket.controller.ts | 3 + .../src/modules/common/ticket.service.ts | 19 + .../modules/ops/admin-invoices.controller.ts | 48 +++ .../modules/ops/admin-tickets.controller.ts | 60 ++- .../src/modules/ops/admin-tickets.service.ts | 395 ++++++++++++++++-- .../dukang-api/src/modules/ops/ops.module.ts | 4 +- .../src/modules/trade/dto/after-sale.dto.ts | 87 ++++ .../src/modules/trade/trade.controller.ts | 59 +++ .../src/modules/trade/trade.module.ts | 4 + .../src/modules/trade/trade.service.ts | 294 ++++++++++++- 34 files changed, 2310 insertions(+), 398 deletions(-) create mode 100644 apps/admin-web/src/pages/InvoicesPage.tsx create mode 100644 apps/h5-user/.env.example create mode 100644 apps/h5-user/src/lib/customer-service.ts create mode 100644 apps/h5-user/src/lib/upload.ts create mode 100644 apps/h5-user/src/pages/AfterSaleListPage.tsx create mode 100644 apps/h5-user/src/pages/AfterSalePage.tsx create mode 100644 apps/h5-user/src/pages/InvoiceApplyPage.tsx create mode 100644 apps/h5-user/src/pages/InvoiceListPage.tsx create mode 100644 packages/shared-types/src/invoice.ts create mode 100644 server/dukang-api/src/modules/ops/admin-invoices.controller.ts create mode 100644 server/dukang-api/src/modules/trade/dto/after-sale.dto.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 7b80897..57fd00b 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -31,6 +31,7 @@ import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import WineryBillsPage from './pages/WineryBillsPage'; import TicketsPage from './pages/TicketsPage'; +import InvoicesPage from './pages/InvoicesPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; @@ -89,6 +90,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index ea65028..1ed8f0d 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -91,6 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [ ], }, { key: '/tickets', icon: , label: '工单中心' }, + { key: '/invoices', icon: , label: '发票管理' }, { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'logs-group', diff --git a/apps/admin-web/src/pages/InvoicesPage.tsx b/apps/admin-web/src/pages/InvoicesPage.tsx new file mode 100644 index 0000000..75fe0fe --- /dev/null +++ b/apps/admin-web/src/pages/InvoicesPage.tsx @@ -0,0 +1,247 @@ +import { useState } from 'react'; +import { + Button, + Descriptions, + Drawer, + Form, + Input, + Select, + Table, + Tag, + Typography, + Upload, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + INVOICE_KIND_LABELS, + INVOICE_STATUS_LABELS, + INVOICE_TITLE_TYPE_LABELS, + type InvoiceKind, + type InvoiceStatus, + type InvoiceTitleType, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; +import { uploadFileToOss } from '../lib/upload'; + +type Row = { + id: string; + invoiceNo: string; + orderNo?: string; + titleType: InvoiceTitleType; + invoiceKind: InvoiceKind; + titleName: string; + status: InvoiceStatus; + overdue?: boolean; + createdAt: string; + fileUrl?: string; + email?: string; + phone?: string; + taxNo?: string; + addressPhone?: string; + bankAccount?: string; + payAmount?: string; + userPhone?: string; + remark?: string; +}; + +export default function InvoicesPage() { + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/invoices', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [uploading, setUploading] = useState(false); + + async function openDetail(id: string) { + setDetail(await request(`/admin/invoices/${id}`)); + setDrawerOpen(true); + } + + async function issueWithFile(file: File) { + if (!detail) return false; + setUploading(true); + try { + const uploaded = await uploadFileToOss(file, { bizType: 'invoice' }); + await request(`/admin/invoices/${detail.id}/issue`, { + method: 'POST', + body: JSON.stringify({ fileUrl: uploaded.url }), + }); + message.success('已开票回传'); + reload(); + setDrawerOpen(false); + } catch (e) { + message.error(e instanceof Error ? e.message : '开票失败'); + } finally { + setUploading(false); + } + return false; + } + + async function reject() { + if (!detail) return; + await request(`/admin/invoices/${detail.id}/reject`, { + method: 'POST', + body: JSON.stringify({ remark: '驳回' }), + }); + message.success('已驳回'); + reload(); + setDrawerOpen(false); + } + + const columns: ColumnsType = [ + { title: '申请单号', dataIndex: 'invoiceNo', width: 180 }, + { title: '订单号', dataIndex: 'orderNo', width: 160 }, + { + title: '抬头', + width: 100, + render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType, + }, + { + title: '票种', + width: 120, + render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind, + }, + { title: '名称', dataIndex: 'titleName', ellipsis: true }, + { + title: '状态', + width: 110, + render: (_, r) => ( + <> + + {INVOICE_STATUS_LABELS[r.status] ?? r.status} + + {r.overdue ? 超时 : null} + + ), + }, + { title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 发票管理 +
{ + setFilters(v); + setPage(1); + }} + > + + + - + + + +
- { setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)} - extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( - <> - - - - ) : null}> +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + setDrawerOpen(false)} + extra={ + detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( + <> + + + + ) : null + } + > {detail && ( {String(detail.ticketNo)} - {String(detail.ticketType)} + + {TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} + {String(detail.status)} - {String(detail.refType)} #{String(detail.refId)} + + {String(detail.refType)} #{String(detail.refId)} + {String(detail.remark ?? '—')} + + {evidenceUrls.length ? ( + + {evidenceUrls.map((url) => ( + + ))} + + ) : ( + '—' + )} + )} diff --git a/apps/h5-user/.env.example b/apps/h5-user/.env.example new file mode 100644 index 0000000..62dfccb --- /dev/null +++ b/apps/h5-user/.env.example @@ -0,0 +1,2 @@ +# 企业微信客服链接(覆盖 shared-types 默认值) +# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx index a7df6f7..99bd957 100644 --- a/apps/h5-user/src/App.tsx +++ b/apps/h5-user/src/App.tsx @@ -20,6 +20,10 @@ import RedeemCodePage from './pages/RedeemCodePage'; import RedeemSuccessPage from './pages/RedeemSuccessPage'; import PayPage from './pages/PayPage'; import CustomerServicePage from './pages/CustomerServicePage'; +import AfterSalePage from './pages/AfterSalePage'; +import AfterSaleListPage from './pages/AfterSaleListPage'; +import InvoiceApplyPage from './pages/InvoiceApplyPage'; +import InvoiceListPage from './pages/InvoiceListPage'; import { UserSessionProvider } from './contexts/UserSessionContext'; import { capturePromoFromUrl } from './lib/promo'; import WechatShareBootstrap from './components/WechatShareBootstrap'; @@ -51,6 +55,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/h5-user/src/components/ContactCustomerSheet.tsx b/apps/h5-user/src/components/ContactCustomerSheet.tsx index 212f2bf..6c81449 100644 --- a/apps/h5-user/src/components/ContactCustomerSheet.tsx +++ b/apps/h5-user/src/components/ContactCustomerSheet.tsx @@ -1,6 +1,6 @@ -import { useNavigate } from 'react-router-dom'; import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import { track } from '../lib/analytics'; +import { openWecomCustomerService } from '../lib/customer-service'; type ContactCustomerSheetProps = { orderId?: string; @@ -8,8 +8,7 @@ type ContactCustomerSheetProps = { onClose: () => void; }; -export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) { - const navigate = useNavigate(); +export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); function openPhone() { @@ -18,13 +17,11 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont onClose(); } - function openChat() { - track('cs_contact', { type: 'chat', orderId }); - const qs = new URLSearchParams(); - if (orderId) qs.set('orderId', orderId); - if (orderNo) qs.set('orderNo', orderNo); - onClose(); - navigate(`/customer-service?${qs.toString()}`); + function openOnline() { + track('cs_contact', { type: 'wecom_kf', orderId }); + if (openWecomCustomerService()) { + onClose(); + } } return ( @@ -49,7 +46,7 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont chevron_right - + + ) : ( +
+ {items.map((t) => ( +
+
+ {TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType} + {STATUS_LABEL[t.status] ?? t.status} +
+

{t.ticketNo}

+

订单 {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}

+ {t.remark ?

{t.remark}

: null} +
+ ))} + +
+ )} + + + ); +} diff --git a/apps/h5-user/src/pages/AfterSalePage.tsx b/apps/h5-user/src/pages/AfterSalePage.tsx new file mode 100644 index 0000000..a437150 --- /dev/null +++ b/apps/h5-user/src/pages/AfterSalePage.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { + AFTER_SALE_TICKET_TYPES, + TICKET_TYPE_LABELS, + type AfterSaleTicketType, +} from '@dukang/shared-types'; +import SubPageHeader from '../components/SubPageHeader'; +import { request } from '../lib/api'; +import { uploadFileToOss } from '../lib/upload'; + +type OrderRow = { + id: string; + orderNo: string; + status: string; + payAmount: number | string; + productName?: string; + createdAt?: string; +}; + +const STEPS = ['类型', '订单', '凭证', '完成'] as const; + +export default function AfterSalePage() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const presetOrderId = params.get('orderId') || ''; + const presetType = (params.get('type') as AfterSaleTicketType | null) || null; + + const [step, setStep] = useState(0); + const [ticketType, setTicketType] = useState( + presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null, + ); + const [orders, setOrders] = useState([]); + const [orderId, setOrderId] = useState(presetOrderId); + const [remark, setRemark] = useState(''); + const [evidenceUrls, setEvidenceUrls] = useState([]); + const [uploading, setUploading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [ticketNo, setTicketNo] = useState(''); + + useEffect(() => { + Promise.all([ + request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'), + request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'), + ]) + .then(([paid, completed]) => { + const map = new Map(); + [...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o)); + setOrders([...map.values()]); + }) + .catch(() => setOrders([])); + }, []); + + const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]); + + async function onPickFiles(files: FileList | null) { + if (!files?.length) return; + setUploading(true); + try { + const uploaded: string[] = []; + for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) { + const res = await uploadFileToOss(file, { bizType: 'after-sale' }); + uploaded.push(res.url); + } + setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6)); + } catch (e) { + window.alert(e instanceof Error ? e.message : '上传失败'); + } finally { + setUploading(false); + } + } + + async function submit() { + if (!ticketType || !orderId) return; + setSubmitting(true); + try { + const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, { + method: 'POST', + body: JSON.stringify({ + ticketType, + remark: remark.trim() || undefined, + evidenceUrls, + }), + }); + setTicketNo(ticket.ticketNo); + setStep(3); + } catch (e) { + window.alert(e instanceof Error ? e.message : '提交失败'); + } finally { + setSubmitting(false); + } + } + + function nextFromType() { + if (!ticketType) { + window.alert('请选择售后类型'); + return; + } + setStep(1); + } + + function nextFromOrder() { + if (!orderId) { + window.alert('请选择订单'); + return; + } + setStep(2); + } + + return ( +
+ navigate(-1)} /> + +
+ {STEPS.map((label, i) => ( + + {label} + + ))} +
+ +
+ {step === 0 && ( +
+ {AFTER_SALE_TICKET_TYPES.map((t) => ( + + ))} + +
+ )} + + {step === 1 && ( +
+ {orders.length === 0 ? ( +

暂无可售后订单

+ ) : ( + orders.map((o) => ( + + )) + )} +
+ + +
+
+ )} + + {step === 2 && ( +
+

+ {ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId} +

+ +