feat: 添加退货审批功能并优化柜机管理页面

- 新增退货审批功能,包括提交审批的API接口、类型定义及页面
- 在订单页面添加“退还”按钮,支持用户提交退货申请
- 优化柜机管理页面布局,使用van-cell代替van-grid展示柜机信息
- 调整路由配置,将柜机管理页面从tabbar中移除,改为在个人中心页面显示
This commit is contained in:
dzq 2025-04-07 15:50:12 +08:00
parent 9c066c0ad9
commit bc46f40285
10 changed files with 413 additions and 144 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -0,0 +1,6 @@
<svg width="80" height="80" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="90" height="90" rx="10" fill="#E8F5E9" stroke="#81C784"
stroke-width="2" />
<text x="50" y="60" font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="#2E7D32" text-anchor="middle">空闲</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@ -0,0 +1,10 @@
import { request } from '@/http/axios'
import { SubmitApprovalRequestData, SubmitApprovalResponseData } from './type'
export const submitApprovalApi = (data: SubmitApprovalRequestData) => {
return request<SubmitApprovalResponseData>({
url: 'approval/submit',
method: 'post',
data
})
}

View File

@ -0,0 +1,11 @@
export interface SubmitApprovalRequestData {
orderId: number
goodsId: number
returnQuantity: number
returnImages: string
}
export type SubmitApprovalResponseData = ApiResponseMsgData<{
approvalId: number
status: number
}>

View File

@ -3,7 +3,7 @@ const router = useRouter()
const tabbarItemList = computed(() => { const tabbarItemList = computed(() => {
const routes = router.getRoutes() const routes = router.getRoutes()
return routes.filter(route => route.meta.layout?.tabbar?.showTabbar && route.path !== '/cabinet') return routes.filter(route => route.meta.layout?.tabbar?.showTabbar)
.map(route => ({ .map(route => ({
title: route.meta.title, title: route.meta.title,
icon: route.meta.layout?.tabbar?.icon, icon: route.meta.layout?.tabbar?.icon,
@ -22,15 +22,6 @@ const wxStore = useWxStoreOutside()
placeholder placeholder
safe-area-inset-bottom safe-area-inset-bottom
> >
<van-tabbar-item
v-if="wxStore.isCabinetAdmin"
key="/cabinet"
to="/cabinet"
icon="cluster-o"
replace
>
柜机管理
</van-tabbar-item>
<van-tabbar-item <van-tabbar-item
v-for="item in tabbarItemList" v-for="item in tabbarItemList"
:key="item.path" :key="item.path"

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from 'vue'
import { showConfirmDialog, showSuccessToast } from 'vant'
import { submitApprovalApi } from '@/common/apis/approval'
import type { SubmitApprovalRequestData } from '@/common/apis/approval/type'
import { useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const formData = ref<SubmitApprovalRequestData>({
orderId: Number(route.query.orderId),
goodsId: Number(route.query.goodsId),
returnQuantity: 1,
returnImages: ''
})
const submitting = ref(false)
const fileList = ref<Array<{ url: string }>>([])
const validateForm = () => {
if (!formData.value.orderId || isNaN(formData.value.orderId)) {
showConfirmDialog({ title: '错误', message: '订单ID参数错误' })
return false
}
if (!formData.value.goodsId || isNaN(formData.value.goodsId)) {
showConfirmDialog({ title: '错误', message: '商品ID参数错误' })
return false
}
if (formData.value.returnQuantity < 1) {
showConfirmDialog({ title: '提示', message: '退还数量至少为1' })
return false
}
return true
}
const handleSubmit = async () => {
if (!validateForm()) return
submitting.value = true
try {
formData.value.returnImages = fileList.value.map(f => f.url).join(',')
const { code } = await submitApprovalApi(formData.value)
if (code === 0) {
showSuccessToast('提交成功')
router.push({ name: 'approval-result', query: { id: formData.value.orderId } })
}
} catch (error) {
showConfirmDialog({
title: '提交失败',
message: error instanceof Error ? error.message : '网络请求异常'
})
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="approval-container">
<van-nav-bar
title="退货审批"
left-text="返回"
left-arrow
fixed
@click-left="() => router.go(-1)"
/>
<div class="content-wrapper">
<van-cell-group>
<!-- 移除订单ID和商品ID的输入框 -->
<van-field
v-model="formData.returnQuantity"
label="退还数量"
type="number"
:min="1"
/>
</van-cell-group>
<van-cell-group class="upload-section">
<van-cell title="凭证图片">
<template #extra>
<van-uploader
v-model="fileList"
multiple
max-count="3"
:after-read="() => {}"
/>
</template>
</van-cell>
</van-cell-group>
<div class="submit-bar">
<van-button
type="primary"
block
:loading="submitting"
loading-text="提交中..."
@click="handleSubmit"
>
提交申请
</van-button>
</div>
</div>
</div>
</template>
<style scoped>
.approval-container {
padding: 12px 16px;
}
.content-wrapper {
padding-top: 46px;
}
.upload-section {
margin: 20px 0;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: #fff;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -4,30 +4,41 @@
<van-sidebar-item v-for="cabinet in cabinetList" :key="cabinet.cabinetId" :title="cabinet.cabinetName" /> <van-sidebar-item v-for="cabinet in cabinetList" :key="cabinet.cabinetId" :title="cabinet.cabinetName" />
</van-sidebar> </van-sidebar>
<div class="locker-grid"> <div class="product-list">
<van-grid :border="false" :column-num="1"> <van-cell v-for="locker in lockerList" :key="locker.lockerId" class="product-item">
<van-grid-item v-for="locker in lockerList" :key="locker.lockerId" class="locker-item">
<template #icon> <template #icon>
<div class="locker-status" :class="[locker.statusClass]"> <van-image width="80" height="80"
<van-image v-if="locker.coverImg" width="100%" height="120" :src="locker.coverImg" :src="locker.coverImg ? locker.coverImg : `${publicPath}` + 'img/product-image.svg'" fit="cover"
fit="cover" radius="4" class="locker-image" /> radius="4" class="product-image">
<div v-else class="status-overlay"> </van-image>
<div class="status-text"> <!-- <div v-else class="status-overlay">
{{ locker.statusClass === 'available' ? '空闲' : '占用' }} <svg width="80" height="80" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
</div> <rect x="5" y="5" width="90" height="90" rx="10" fill="#E8F5E9" stroke="#81C784"
</div> stroke-width="2" />
</div> <text x="50" y="60" font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="#2E7D32" text-anchor="middle">空闲</text>
</svg>
</div> -->
</template> </template>
<template #text> <div class="product-info">
<div class="locker-info"> <div class="goods-info">
<div v-if="locker.goodsName">
<div class="info-row">
<div class="locker-number">格口 {{ locker.lockerNumber }}</div> <div class="locker-number">格口 {{ locker.lockerNumber }}</div>
<div v-if="locker.goodsName" class="goods-info"> <div class="goods-price">¥{{ (locker.price || 0).toFixed(2) }}</div>
<div class="goods-name van-ellipsis">{{ locker.goodsName }}</div> </div>
<div class="goods-price">¥{{ locker.price?.toFixed(2) }}</div> <div class="goods-name">{{ locker.goodsName }}</div>
</div>
<div v-else>
<div class="info-row">
<div class="locker-number">格口 {{ locker.lockerNumber }}</div>
<div class="goods-price">¥0.00</div>
</div>
<div class="goods-name">空闲</div>
</div>
</div> </div>
<div class="button-group"> <div class="button-group">
<van-button size="small" type="primary" class="detail-btn" <van-button size="small" type="primary" class="detail-btn" @click="showLockerDetail(locker)">
@click="showLockerDetail(locker)">
详情 详情
</van-button> </van-button>
<van-button size="small" plain hairline :loading="openingLockerId === locker.lockerId" <van-button size="small" plain hairline :loading="openingLockerId === locker.lockerId"
@ -36,9 +47,7 @@
</van-button> </van-button>
</div> </div>
</div> </div>
</template> </van-cell>
</van-grid-item>
</van-grid>
</div> </div>
</div> </div>
</template> </template>
@ -48,6 +57,7 @@ import { ref } from 'vue'
import { getCabinetDetailApi, openCabinet } from '@/common/apis/cabinet' import { getCabinetDetailApi, openCabinet } from '@/common/apis/cabinet'
import type { CabinetDetailDTO } from '@/common/apis/cabinet/type' import type { CabinetDetailDTO } from '@/common/apis/cabinet/type'
import { useWxStoreOutside } from '@/pinia/stores/wx' import { useWxStoreOutside } from '@/pinia/stores/wx'
import { publicPath } from "@/common/utils/path"
const wxStore = useWxStoreOutside() const wxStore = useWxStoreOutside()
@ -167,37 +177,7 @@ loadCabinetDetail()
} }
} }
.locker-item { .status-overlay {
width: 100%;
margin: 8px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.locker-status {
position: relative;
border-radius: 4px;
overflow: hidden;
.locker-image {
display: block;
}
.status-overlay {
position: absolute;
width: 100%;
height: 50px;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -207,28 +187,27 @@ loadCabinetDetail()
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
} }
} }
}
.locker-info { .locker-number {
padding: 8px;
.locker-number {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
margin-bottom: 4px; margin-bottom: 4px;
} }
.goods-info { .goods-info {
margin: 8px 0; display: flex;
padding: 8px; flex-direction: column;
background: #f7f8fa; gap: 8px;
border-radius: 4px; width: 100%;
.goods-name { .goods-name {
white-space: normal;
word-wrap: break-word;
font-size: 12px; font-size: 12px;
color: #333; color: #666;
line-height: 1.4; line-height: 1.2;
width: 70%;
} }
.goods-price { .goods-price {
@ -237,12 +216,12 @@ loadCabinetDetail()
font-weight: bold; font-weight: bold;
margin-top: 4px; margin-top: 4px;
} }
} }
.button-group { .button-group {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 0;
.detail-btn { .detail-btn {
flex: 1; flex: 1;
@ -254,7 +233,83 @@ loadCabinetDetail()
color: #e95d5d; color: #e95d5d;
border-color: #e95d5d; border-color: #e95d5d;
} }
} }
}
.product-list {
flex: 1;
overflow-y: auto;
padding: 10px 16px 60px;
background: #ffffff;
}
.product-item {
margin-bottom: 10px;
padding: min(2.667vw, 20px) 0;
}
.product-info {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100px;
position: relative;
padding-left: 3px;
}
.goods-name {
text-align: left;
}
.button-group {
padding: 4px;
}
.button-group .van-button {
font-size: 12px;
height: 24px;
line-height: 22px;
}
:deep(.van-button) {
--van-button-mini-height: 24px;
--van-button-mini-padding: 0 8px;
}
.locker-number {
font-size: 14px;
color: #333;
line-height: 1.4;
text-align: left;
}
.goods-price {
font-size: 16px;
color: #e95d5d;
font-weight: bold;
text-align: left;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.detail-btn {
flex: 1;
background-color: #e95d5d;
border: none;
}
:deep(.van-button--default) {
color: #e95d5d;
border-color: #e95d5d;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
} }
</style> </style>

View File

@ -74,6 +74,7 @@ const balance = computed(() => wxStore.balance)
<!-- 个人中心按钮 --> <!-- 个人中心按钮 -->
<van-cell-group> <van-cell-group>
<van-cell title="订单列表" is-link @click="router.push('/order-list')" /> <van-cell title="订单列表" is-link @click="router.push('/order-list')" />
<van-cell title="柜机管理" is-link @click="router.push('/cabinet')" v-if="wxStore.isCabinetAdmin"/>
</van-cell-group> </van-cell-group>
</div> </div>
</template> </template>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useOrderStore } from '@/pinia/stores/order' import { useOrderStore } from '@/pinia/stores/order'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
const orderStore = useOrderStore() const orderStore = useOrderStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const statusMap: Record<number, string> = { const statusMap: Record<number, string> = {
1: '待付款', 1: '待付款',
@ -29,9 +30,20 @@ const order = computed(() => {
return orderStore.orders.find(o => o.orderId === orderId.value) return orderStore.orders.find(o => o.orderId === orderId.value)
}) })
const handleRefund = (item: any) => {
if (order.value?.status !== 4) return
router.push({
path: '/approval/submit',
query: {
goodsId: item.goodsInfo.goodsId,
orderId: order.value.orderId
}
})
}
onMounted(() => { onMounted(() => {
if (!order.value) { if (!order.value) {
// Handle case when order is not found //
} }
}) })
</script> </script>
@ -62,15 +74,26 @@ onMounted(() => {
/> />
</template> </template>
<div class="product-info"> <div class="product-info">
<div class="product-name-price">
<div class="product-name van-ellipsis"> <div class="product-name van-ellipsis">
{{ item.goodsInfo.goodsName }} {{ item.goodsInfo.goodsName }}
</div> </div>
<div class="product-price"> <div class="product-price">
¥{{ item.goodsInfo.price.toFixed(2) }} ¥{{ item.orderGoods.price.toFixed(2) }}
</div>
</div> </div>
<div class="action-row"> <div class="action-row">
<p>数量: {{ item.orderGoods.quantity }}</p> <p>数量: {{ item.orderGoods.quantity }}</p>
<p>小计: ¥{{ (item.orderGoods.price * item.orderGoods.quantity).toFixed(2) }}</p> <p>小计: ¥{{ (item.orderGoods.price * item.orderGoods.quantity).toFixed(2) }}</p>
<van-button
v-if="order.status === 4"
type="primary"
size="mini"
class="refund-btn"
@click="handleRefund(item)"
>
退还
</van-button>
</div> </div>
</div> </div>
</van-cell> </van-cell>
@ -121,9 +144,39 @@ goods-info {
flex: 1; flex: 1;
} }
.product-name-price {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.product-name {
flex: 1;
margin-right: 10px;
}
.product-price {
color: #ee0a24;
font-weight: 500;
}
.not-found { .not-found {
padding: 40px; padding: 40px;
text-align: center; text-align: center;
color: #999; color: #999;
} }
.refund-btn {
margin-left: auto;
color: #fff;
background: #ee0a24;
border-radius: 15px;
padding: 0 12px;
}
.action-row {
display: flex;
gap: 8px;
align-items: center;
}
</style> </style>

View File

@ -29,6 +29,11 @@ export const systemRoutes: RouteRecordRaw[] = [
/** 业务页面 */ /** 业务页面 */
export const routes: RouteRecordRaw[] = [ export const routes: RouteRecordRaw[] = [
{
path: '/approval/submit',
component: () => import('@/pages/approval/submit.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/order-success', path: '/order-success',
name: 'OrderSuccess', name: 'OrderSuccess',
@ -64,11 +69,11 @@ export const routes: RouteRecordRaw[] = [
} }
}, },
{ {
path: "/", path: '/cabinet',
component: () => import("@/pages/product/ProductList.vue"), component: () => import('@/pages/cabinet/index.vue'),
name: "ProductList", name: "Cabinet",
meta: { meta: {
title: "商品列表", title: '柜机管理',
keepAlive: true, keepAlive: true,
layout: { layout: {
navBar: { navBar: {
@ -76,18 +81,18 @@ export const routes: RouteRecordRaw[] = [
showLeftArrow: false showLeftArrow: false
}, },
tabbar: { tabbar: {
showTabbar: true, showTabbar: false,
icon: "home-o" icon: "home-o"
} }
} }
} }
}, },
{ {
path: '/cabinet', path: "/",
component: () => import('@/pages/cabinet/index.vue'), component: () => import("@/pages/product/ProductList.vue"),
name: "Cabinet", name: "ProductList",
meta: { meta: {
title: '柜机管理', title: "商品列表",
keepAlive: true, keepAlive: true,
layout: { layout: {
navBar: { navBar: {
@ -122,6 +127,11 @@ export const routes: RouteRecordRaw[] = [
] ]
/* export const routes: RouteRecordRaw[] = [ /* export const routes: RouteRecordRaw[] = [
{
path: '/approval/submit',
component: () => import('@/pages/approval/submit.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/order-success', path: '/order-success',
name: 'OrderSuccess', name: 'OrderSuccess',