OrderManage.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. <template>
  2. <div class="order-manage admin-page">
  3. <div class="header">
  4. <h2 class="admin-page-title">订单管理</h2>
  5. <div class="filters filter-bar">
  6. <el-input v-model="keyword" placeholder="搜索商品/订单号..." prefix-icon="el-icon-search" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" style="width:220px;" />
  7. <el-date-picker
  8. v-model="dateRange"
  9. type="daterange"
  10. range-separator="至"
  11. start-placeholder="开始日期"
  12. end-placeholder="结束日期"
  13. value-format="yyyy-MM-dd"
  14. style="width:260px;"
  15. @change="handleSearch"
  16. />
  17. <el-select v-model="statusFilter" placeholder="订单状态" @change="handleSearch" clearable>
  18. <el-option label="全部状态" value="" />
  19. <el-option label="待支付" value="pending" />
  20. <el-option label="已支付(待发货)" value="paid" />
  21. <el-option label="待收货" value="pending_receipt" />
  22. <el-option label="已完成" value="completed" />
  23. <el-option label="已取消" value="cancelled" />
  24. <el-option label="退款中" value="refunding" />
  25. <el-option label="退款到帐中" value="refund_pending" />
  26. <el-option label="已退款" value="refunded" />
  27. <el-option label="已关闭" value="closed" />
  28. </el-select>
  29. <el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
  30. </div>
  31. </div>
  32. <div class="table-scroll-wrap-lg">
  33. <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
  34. <el-table-column prop="orderNo" label="订单号" width="180" />
  35. <el-table-column label="商品" min-width="160">
  36. <template slot-scope="{ row }">
  37. <div style="display:flex;align-items:center;gap:8px;">
  38. <img v-if="row.coverImage" :src="row.coverImage" style="width:40px;height:40px;object-fit:cover;border-radius:4px;" />
  39. <span>{{ row.productName || '-' }}</span>
  40. </div>
  41. </template>
  42. <template slot="header">
  43. <span class="sort-header" @click.stop="onSortClick( + col_key + ',$event)" @dblclick.stop="onSortToggle( + col_key + )">订单号{{ sortIcon( + col_key + ) }}</span>
  44. </template></el-table-column>
  45. <el-table-column prop="quantity" label="数量" width="60" align="center" />
  46. <el-table-column label="金额" width="100">
  47. <template slot-scope="{ row }">
  48. {{ formatPrice(row.totalAmount) }}
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="状态" width="100">
  52. <template slot-scope="{ row }">
  53. <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
  54. </template>
  55. </el-table-column>
  56. <el-table-column label="履约状态" width="120">
  57. <template slot-scope="{ row }">
  58. <el-tag v-if="row.fulfillStatus" :type="fulfillType(row.fulfillStatus)" size="mini">{{ fulfillLabel(row.fulfillStatus) }}</el-tag>
  59. <span v-else style="color:#999;">-</span>
  60. </template>
  61. </el-table-column>
  62. <el-table-column prop="paymentMethod" label="支付方式" width="100">
  63. <template slot-scope="{ row }">
  64. {{ payMethodLabel(row.paymentMethod) }}
  65. </template>
  66. </el-table-column>
  67. <el-table-column label="下单时间" width="155">
  68. <template slot-scope="{ row }">
  69. {{ formatTime(row.createdAt) }}
  70. </template>
  71. </el-table-column>
  72. <el-table-column label="操作" width="200" fixed="right">
  73. <template slot-scope="{ row }">
  74. <el-button size="mini" type="primary" @click="viewDetail(row)">详情</el-button>
  75. <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
  76. <el-button size="mini">
  77. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  78. </el-button>
  79. <el-dropdown-menu slot="dropdown">
  80. <el-dropdown-item v-if="row.status === 'paid'" command="vendor-confirm" icon="el-icon-check">确认发货</el-dropdown-item>
  81. <el-dropdown-item v-if="row.status === 'paid'" command="ship" icon="el-icon-s-cooperation">发货</el-dropdown-item>
  82. <el-dropdown-item v-if="row.status === 'paid' || row.status === 'refunding'" command="close" icon="el-icon-circle-close">关闭</el-dropdown-item>
  83. <el-dropdown-item v-if="row.status === 'refunding'" command="refund" icon="el-icon-warning-outline">退款审核</el-dropdown-item>
  84. <el-dropdown-item v-if="row.status === 'refund_pending'" command="view-pending-refund" icon="el-icon-warning">退款到帐中</el-dropdown-item>
  85. </el-dropdown-menu>
  86. </el-dropdown>
  87. </template>
  88. </el-table-column>
  89. </el-table>
  90. </div>
  91. <el-pagination
  92. @size-change="onSizeChange"
  93. @current-change="onPageChange"
  94. :current-page="page"
  95. :page-size="size"
  96. :total="total"
  97. layout="total, sizes, prev, pager, next"
  98. :page-sizes="[10, 20, 50]"
  99. class="pagination-wrap"
  100. />
  101. <!-- Detail Dialog -->
  102. <el-dialog title="订单详情" :visible.sync="detailDialogVisible" width="700px" top="5vh">
  103. <template v-if="detail">
  104. <el-descriptions :column="2" border>
  105. <el-descriptions-item label="订单编号" :span="2">{{ detail.orderNo }}</el-descriptions-item>
  106. <el-descriptions-item label="商品名称">{{ detail.productName || '-' }}</el-descriptions-item>
  107. <el-descriptions-item label="商品类型">{{ typeLabel(detail.productType) }}</el-descriptions-item>
  108. <el-descriptions-item label="数量">{{ detail.quantity }}</el-descriptions-item>
  109. <el-descriptions-item label="单价">{{ formatPrice(detail.unitPrice) }}</el-descriptions-item>
  110. <el-descriptions-item label="总金额">{{ formatPrice(detail.totalAmount) }}</el-descriptions-item>
  111. <el-descriptions-item label="优惠金额">{{ detail.discountAmount ? formatPrice(detail.discountAmount) : '-' }}</el-descriptions-item>
  112. <el-descriptions-item label="订单状态">
  113. <el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
  114. </el-descriptions-item>
  115. <el-descriptions-item label="支付方式">{{ payMethodLabel(detail.paymentMethod) }}</el-descriptions-item>
  116. <el-descriptions-item label="支付时间">{{ detail.paidAt ? formatTime(detail.paidAt) : '-' }}</el-descriptions-item>
  117. <el-descriptions-item label="买家备注">{{ detail.remark || '-' }}</el-descriptions-item>
  118. <el-descriptions-item label="下单时间">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
  119. <el-descriptions-item label="配送方式" v-if="detail.deliveryMethod">
  120. {{ deliveryMethodLabel(detail.deliveryMethod) }}
  121. </el-descriptions-item>
  122. <el-descriptions-item label="履约状态" v-if="detail.fulfillStatus">
  123. <el-tag :type="fulfillType(detail.fulfillStatus)" size="mini">{{ fulfillLabel(detail.fulfillStatus) }}</el-tag>
  124. </el-descriptions-item>
  125. <el-descriptions-item label="自取地点" v-if="detail.pickupLocationSnapshot">{{ getJsonField(detail.pickupLocationSnapshot, 'name') }}</el-descriptions-item>
  126. <el-descriptions-item label="服务者" v-if="detail.servicePersonSnapshot">{{ getJsonField(detail.servicePersonSnapshot, 'name') }}</el-descriptions-item>
  127. <el-descriptions-item label="确认时间" v-if="detail.fulfillConfirmedAt">{{ formatTime(detail.fulfillConfirmedAt) }}</el-descriptions-item>
  128. <el-descriptions-item label="收货时间" v-if="detail.receivedAt">{{ formatTime(detail.receivedAt) }}</el-descriptions-item>
  129. </el-descriptions>
  130. </template>
  131. <span slot="footer">
  132. <el-button @click="detailDialogVisible = false">关闭</el-button>
  133. </span>
  134. </el-dialog>
  135. <!-- Ship Dialog -->
  136. <el-dialog title="发货" :visible.sync="shipDialogVisible" width="450px">
  137. <el-alert title="确认给以下订单发货" type="info" :closable="false" style="margin-bottom:15px;">
  138. <template slot>
  139. 订单号:<strong>{{ shipTarget?.orderNo }}</strong>
  140. </template>
  141. </el-alert>
  142. <el-form :model="shipForm" label-width="90px">
  143. <el-form-item label="物流公司">
  144. <el-input v-model="shipForm.expressCompany" placeholder="如:顺丰、中通" />
  145. </el-form-item>
  146. <el-form-item label="物流单号">
  147. <el-input v-model="shipForm.trackingNumber" placeholder="请输入物流单号" />
  148. </el-form-item>
  149. <el-form-item label="快递费(元)">
  150. <el-input v-model.number="shipForm.shippingCost" type="number" placeholder="0" />
  151. </el-form-item>
  152. </el-form>
  153. <span slot="footer">
  154. <el-button @click="shipDialogVisible = false">取消</el-button>
  155. <el-button type="primary" @click="confirmShip" :loading="shipLoading">确认发货</el-button>
  156. </span>
  157. </el-dialog>
  158. <!-- Refund Dialog -->
  159. <el-dialog title="退款审核" :visible.sync="refundDialogVisible" width="450px">
  160. <template v-if="refundTarget">
  161. <el-alert title="退款申请" type="warning" :closable="false" style="margin-bottom:15px;">
  162. <template slot>
  163. 订单号:<strong>{{ refundTarget.orderNo }}</strong><br>
  164. 退款原因:{{ refundTarget.refundReason || '-' }}
  165. </template>
  166. </el-alert>
  167. <el-input v-model="refundRejectReason" type="textarea" placeholder="拒绝原因(选填)" :rows="3" />
  168. </template>
  169. <span slot="footer">
  170. <el-button @click="refundDialogVisible = false">取消</el-button>
  171. <el-button type="danger" @click="handleRejectRefund" :loading="refundLoading">拒绝退款</el-button>
  172. <el-button type="success" @click="handleApproveRefundConfirm" :loading="refundLoading">同意退款</el-button>
  173. </span>
  174. </el-dialog>
  175. </div>
  176. </template>
  177. <script>
  178. import SortMixin from '@/mixins/SortMixin'
  179. import { getOrderList, shipOrder, closeOrder, approveRefund, rejectRefund, vendorConfirmOrder } from '@/api/admin'
  180. export default {
  181. mixins: [SortMixin],
  182. name: 'OrderManage',
  183. computed: {
  184. tableHeight() {
  185. return window.innerHeight - 340
  186. }
  187. },
  188. data() {
  189. return {
  190. list: [],
  191. loading: false,
  192. page: 1,
  193. size: 20,
  194. total: 0,
  195. statusFilter: '',
  196. keyword: '',
  197. dateRange: null,
  198. detailDialogVisible: false,
  199. detail: null,
  200. shipDialogVisible: false,
  201. shipTarget: null,
  202. shipLoading: false,
  203. shipForm: { expressCompany: '', trackingNumber: '', shippingCost: '' },
  204. refundDialogVisible: false,
  205. refundTarget: null,
  206. refundLoading: false,
  207. refundRejectReason: ''
  208. }
  209. },
  210. mounted() {
  211. this.loadList()
  212. },
  213. methods: {
  214. handleSearch() {
  215. this.page = 1
  216. this.loadList()
  217. },
  218. async loadList() {
  219. this.loading = true
  220. try {
  221. var params = { page: this.page, size: this.size }
  222. if (this.statusFilter) {
  223. params.status = this.statusFilter
  224. }
  225. if (this.keyword) {
  226. params.keyword = this.keyword
  227. }
  228. if (this.dateRange && this.dateRange.length === 2) {
  229. params.startDate = this.dateRange[0]
  230. params.endDate = this.dateRange[1]
  231. }
  232. var res = await getOrderList(params)
  233. this.list = res.data.records || []
  234. this.total = res.data.total || 0
  235. this.page = res.data.page || this.page
  236. this.size = res.data.size || this.size
  237. } catch (e) {
  238. this.$message.error('加载订单列表失败')
  239. } finally {
  240. this.loading = false
  241. }
  242. },
  243. onPageChange(val) {
  244. this.page = val
  245. this.loadList()
  246. },
  247. onSizeChange(val) {
  248. this.size = val
  249. this.page = 1
  250. this.loadList()
  251. },
  252. viewDetail(row) {
  253. this.detail = row
  254. this.detailDialogVisible = true
  255. },
  256. showShipDialog(row) {
  257. this.shipTarget = row
  258. this.shipForm = { expressCompany: '', trackingNumber: '', shippingCost: '' }
  259. this.shipDialogVisible = true
  260. },
  261. async confirmShip() {
  262. if (!this.shipForm.trackingNumber.trim()) {
  263. return this.$message.warning('请填写物流单号')
  264. }
  265. this.shipLoading = true
  266. try {
  267. const shippingCost = this.shipForm.shippingCost ? Math.round(parseFloat(this.shipForm.shippingCost) * 100) : null
  268. await shipOrder(this.shipTarget.orderNo, this.shipForm.trackingNumber, this.shipForm.expressCompany, { shippingCost })
  269. this.$message.success('发货成功')
  270. this.shipDialogVisible = false
  271. this.loadList()
  272. } catch (e) {
  273. this.$message.error(e.message || '发货失败')
  274. } finally {
  275. this.shipLoading = false
  276. }
  277. },
  278. async handleClose(row) {
  279. try {
  280. await this.$confirm('确定关闭订单 ' + row.orderNo + '?', '提示', { type: 'warning' })
  281. await closeOrder(row.orderNo)
  282. this.$message.success('订单已关闭')
  283. this.loadList()
  284. } catch (e) {
  285. if (e !== 'cancel') {
  286. this.$message.error(e.message || '关闭失败')
  287. }
  288. }
  289. },
  290. async handleVendorConfirm(row) {
  291. try {
  292. await this.$confirm('确认供应商已发货/履约完成?', '提示', { type: 'info' })
  293. await vendorConfirmOrder(row.orderNo)
  294. this.$message.success('已确认发货')
  295. this.loadList()
  296. } catch (e) {
  297. if (e !== 'cancel') {
  298. this.$message.error(e.message || '操作失败')
  299. }
  300. }
  301. },
  302. showRefundDialog(row) {
  303. this.refundTarget = row
  304. this.refundRejectReason = ''
  305. this.refundDialogVisible = true
  306. },
  307. async handleApproveRefundConfirm() {
  308. this.refundLoading = true
  309. try {
  310. await this.$confirm('确认同意退款?', '提示', { type: 'warning' })
  311. var res = await approveRefund(this.refundTarget.orderNo)
  312. this.$message.success(res.message || '已同意退款')
  313. this.refundDialogVisible = false
  314. this.loadList()
  315. } catch (e) {
  316. if (e !== 'cancel') {
  317. this.$message.error(e.message || '操作失败')
  318. }
  319. } finally {
  320. this.refundLoading = false
  321. }
  322. },
  323. async handleRejectRefund() {
  324. this.refundLoading = true
  325. try {
  326. await this.$confirm('确认拒绝退款?', '提示', { type: 'warning' })
  327. await rejectRefund(this.refundTarget.orderNo, this.refundRejectReason)
  328. this.$message.success('已拒绝退款')
  329. this.refundDialogVisible = false
  330. this.loadList()
  331. } catch (e) {
  332. if (e !== 'cancel') {
  333. this.$message.error(e.message || '操作失败')
  334. }
  335. } finally {
  336. this.refundLoading = false
  337. }
  338. },
  339. canClose(status) {
  340. return ['pending', 'paid', 'pending_receipt', 'refunding'].includes(status);
  341. },
  342. statusType(status) {
  343. var map = { pending: 'warning', paid: 'primary', pending_receipt: '', completed: 'success', cancelled: 'info', refunding: 'danger', refunded: 'danger', closed: 'info', refund_pending: 'warning' }
  344. return map[status] || 'info'
  345. },
  346. statusLabel(status) {
  347. var map = { pending: '待支付', paid: '待发货', pending_receipt: '待收货', completed: '已完成', cancelled: '已取消', refunding: '退款中', refunded: '已退款', closed: '已关闭', refund_pending: '退款到帐中' }
  348. return map[status] || status
  349. },
  350. payMethodLabel(method) {
  351. var map = { wechat: '微信支付', balance: '余额支付', points: '积分兑换', test: '测试支付' }
  352. return map[method] || method || '-'
  353. },
  354. fulfillLabel(status) {
  355. var map = {
  356. 'pending': '待履约',
  357. 'confirmed_by_vendor': '已确认',
  358. 'received_by_user': '已收货',
  359. 'completed': '已完成'
  360. }
  361. return map[status] || status
  362. },
  363. fulfillType(status) {
  364. var map = {
  365. 'pending': 'warning',
  366. 'confirmed_by_vendor': 'primary',
  367. 'received_by_user': 'success',
  368. 'completed': 'success'
  369. }
  370. return map[status] || 'info'
  371. },
  372. typeLabel(type) {
  373. var map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券' }
  374. return map[type] || type || '-'
  375. },
  376. formatPrice(cents) {
  377. if (cents == null) return '-'
  378. return '¥' + (cents / 100).toFixed(2)
  379. },
  380. deliveryMethodLabel(method) {
  381. var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
  382. return map[method] || '快递'
  383. },
  384. getJsonField(snapshot, field) {
  385. if (!snapshot) return '-'
  386. try {
  387. var obj = JSON.parse(snapshot)
  388. return obj[field] || '-'
  389. } catch (e) {
  390. return snapshot
  391. }
  392. },
  393. formatTime(t) {
  394. if (!t) return '-'
  395. return t.replace ? t.replace('T', ' ').substring(0, 19) : t
  396. },
  397. handleActionCmd(row, cmd) {
  398. switch (cmd) {
  399. case 'ship': this.showShipDialog(row); break;
  400. case 'vendor-confirm': this.handleVendorConfirm(row); break;
  401. case 'close': this.handleClose(row); break;
  402. case 'refund': this.showRefundDialog(row); break;
  403. case 'view-pending-refund': this.$router.push('/pending-refund'); break;
  404. }
  405. }
  406. }
  407. }
  408. </script>
  409. <style scoped>
  410. .order-manage { padding: 20px; }
  411. .header {
  412. display: flex;
  413. justify-content: space-between;
  414. align-items: center;
  415. margin-bottom: 20px;
  416. flex-wrap: wrap;
  417. }
  418. .header h2 { margin: 0; }
  419. .filters {
  420. display: flex;
  421. gap: 10px;
  422. align-items: center;
  423. }
  424. .sort-header {
  425. cursor: pointer;
  426. user-select: none;
  427. font-weight: 500;
  428. font-size: 12px;
  429. }
  430. .sort-header:hover {
  431. color: #409eff;
  432. }
  433. .sort-tag {
  434. margin-left: 8px;
  435. cursor: pointer;
  436. white-space: nowrap;
  437. }
  438. </style>