feat(groupbuying): 完善拼团订单全链路及错误处理

- 拼团订单发货校验:必须成团后才能发货
- 限购校验:跨团组统计用户参与次数,超限拒绝
- 成团/失败自动通知所有成员(Redis Pub/Sub)
- 拼团详情页区分"进行中"和"已成团"团组展示
- 订单类型支持三态(普通/秒杀/拼团)前后端联调
- 400错误只显示业务消息,不再重复弹出状态码
- 响应式导航栏适配及UI优化
- 新增历史数据修复SQL脚本
This commit is contained in:
2026-03-17 00:08:21 +08:00
parent 32c1113d4a
commit 28f41754d0
22 changed files with 571 additions and 58 deletions

View File

@@ -85,34 +85,47 @@ service.interceptors.response.use(
},
(error) => {
console.error('响应错误:', error)
// 提取后端返回的业务错误消息
const bizMessage = error.response?.data?.message
let displayMessage = ''
if (error.response) {
switch (error.response.status) {
case 400:
// 业务错误(如"该团组已满员"),只显示后端返回的消息
displayMessage = bizMessage || '请求参数错误'
break
case 401:
ElMessage.error('未授权,请登录')
displayMessage = '未授权,请登录'
break
case 403:
ElMessage.error('拒绝访问')
displayMessage = '拒绝访问'
break
case 404:
ElMessage.error('请求地址不存在')
displayMessage = '请求地址不存在'
break
case 429:
ElMessage.error('请求过于频繁,请稍后再试')
displayMessage = '请求过于频繁,请稍后再试'
break
case 500:
ElMessage.error('服务器内部错误')
displayMessage = '服务器内部错误'
break
default:
ElMessage.error(error.response.data?.message || '请求失败')
displayMessage = bizMessage || '请求失败'
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
displayMessage = '网络错误,请检查网络连接'
} else {
ElMessage.error('请求配置错误')
displayMessage = '请求配置错误'
}
return Promise.reject(error)
ElMessage.error(displayMessage)
// 用业务消息替换原始 axios error message避免组件 catch 显示 "Request failed with status code 400"
const rejectError = new Error(displayMessage)
;(rejectError as any)._handled = true
return Promise.reject(rejectError)
}
)

View File

@@ -107,7 +107,10 @@ const reviewedItems = computed(() => items.value.filter(i => i.reviewed))
const canSubmit = computed(() => reviewableItems.value.some(i => i.content.trim()))
const loadReviewStatus = async () => {
if (!props.orderId || !props.orderItems.length) return
if (!props.orderId || !props.orderItems.length) {
items.value = []
return
}
checkLoading.value = true
try {
const list: ReviewableItem[] = props.orderItems.map(item => ({
@@ -177,5 +180,14 @@ const handleSubmit = async () => {
watch(() => props.visible, (val) => {
if (val) loadReviewStatus()
if (!val) items.value = []
})
watch(
() => [props.orderId, props.orderItems],
() => {
if (props.visible) loadReviewStatus()
},
{ immediate: true }
)
</script>

View File

@@ -1,9 +1,8 @@
<template>
<header class="app-header">
<div class="container mx-auto px-4">
<nav class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex items-center">
<nav class="header-nav">
<div class="header-brand">
<router-link to="/" class="brand-link">
<el-icon :size="24" class="brand-icon">
<Lightning />
@@ -14,9 +13,8 @@
</span>
</router-link>
</div>
<!-- 导航菜单 -->
<div class="hidden md:flex items-center space-x-8">
<div class="header-links hidden xl:flex items-center">
<router-link to="/" class="nav-link">
<el-icon><HomeFilled /></el-icon>
首页
@@ -49,22 +47,35 @@
拼团
</router-link>
</div>
<!-- 右侧菜单 -->
<div class="flex items-center space-x-4">
<SearchComponent />
<!-- 通知中心 -->
<div class="header-actions">
<el-dropdown class="header-menu xl:hidden" trigger="click" @command="handleMainNavCommand">
<button type="button" class="menu-trigger" aria-label="打开导航菜单">
<el-icon :size="18"><Menu /></el-icon>
<span class="hidden sm:inline">菜单</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="/">首页</el-dropdown-item>
<el-dropdown-item command="/flashsales">秒杀活动</el-dropdown-item>
<el-dropdown-item command="/products">商品列表</el-dropdown-item>
<el-dropdown-item command="/groupbuying">拼团</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="header-search hidden lg:block">
<SearchComponent />
</div>
<NotificationCenter v-if="userStore.isLoggedIn" />
<!-- 购物车 -->
<router-link to="/cart" class="cart-link relative">
<el-badge :value="cartCount" :hidden="cartCount === 0" class="cart-badge">
<el-icon :size="20"><ShoppingCart /></el-icon>
</el-badge>
</router-link>
<!-- 用户菜单 -->
<template v-if="userStore.isLoggedIn">
<el-dropdown trigger="click">
<div class="user-trigger flex items-center space-x-2 cursor-pointer">
@@ -107,8 +118,7 @@
</template>
</el-dropdown>
</template>
<!-- 未登录 -->
<template v-else>
<el-button text @click="router.push('/login')">登录</el-button>
<el-button type="primary" @click="router.push('/register')">注册</el-button>
@@ -157,6 +167,12 @@ const handleCategoryCommand = (category: string) => {
}
}
const handleMainNavCommand = (path: string) => {
if (path) {
router.push(path)
}
}
// 退出登录
const handleLogout = async () => {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
@@ -193,11 +209,43 @@ onMounted(() => {
border-bottom: 1px solid #d8cebf;
}
.header-nav {
min-height: var(--app-header-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.header-brand,
.header-actions {
display: flex;
align-items: center;
min-width: 0;
}
.header-actions {
gap: 12px;
flex-shrink: 0;
:deep(.el-button) {
font-size: 14px;
}
}
.header-links {
gap: 28px;
flex: 1;
justify-content: center;
min-width: 0;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
color: #171715;
min-width: 0;
}
.brand-icon {
@@ -213,9 +261,10 @@ onMounted(() => {
}
.brand-title {
font-size: 20px;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
}
.brand-tag {
@@ -227,6 +276,7 @@ onMounted(() => {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
white-space: nowrap;
}
.nav-link {
@@ -234,6 +284,7 @@ onMounted(() => {
align-items: center;
gap: 4px;
padding: 8px 2px;
font-size: 14px;
color: #5e5e58;
text-decoration: none;
transition: color 0.25s ease;
@@ -267,7 +318,7 @@ onMounted(() => {
}
}
.cart-link,
.menu-trigger,
.user-trigger {
display: flex;
align-items: center;
@@ -278,6 +329,32 @@ onMounted(() => {
border: 1px solid #d8cebf;
background: #fffaf2;
color: #2b2b27;
font-size: 14px;
}
.menu-trigger {
gap: 6px;
cursor: pointer;
}
.cart-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 6px;
color: #2b2b27;
background: transparent;
border: 1px solid transparent;
border-radius: 0;
box-shadow: none;
}
.header-search {
:deep(.search-input) {
width: clamp(180px, 18vw, 280px);
}
}
.cart-badge {
@@ -287,4 +364,31 @@ onMounted(() => {
border: 1px solid #171715;
}
}
@media (max-width: 1279px) {
.brand-tag {
display: none;
}
}
@media (max-width: 767px) {
.header-nav {
gap: 12px;
}
.brand-title {
font-size: 16px;
}
.header-actions {
gap: 8px;
}
.cart-link,
.user-trigger,
.menu-trigger {
min-height: 36px;
padding: 0 10px;
}
}
</style>

View File

@@ -286,11 +286,13 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid #d8cebf;
border-radius: 999px;
background: #fffaf2;
width: 44px;
height: 44px;
padding: 6px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
color: #2b2b27;
&:hover {
color: #171715;

View File

@@ -26,7 +26,7 @@ import AppFooter from '@/components/common/AppFooter.vue'
.main-content {
flex: 1;
padding-top: 60px; // header高度
padding-top: var(--app-header-height);
background: transparent;
}

View File

@@ -47,6 +47,8 @@
<el-option label="已发货" value="3" />
<el-option label="已完成" value="4" />
<el-option label="已取消" value="5" />
<el-option label="退货中" value="6" />
<el-option label="已退货" value="7" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
@@ -63,7 +65,7 @@
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.isFlashSale ? 'danger' : 'info'">{{ row.isFlashSale ? '秒杀订单' : '普通订单' }}</el-tag>
<el-tag :type="getOrderTypeTag(row.orderType)">{{ getOrderTypeText(row.orderType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
@@ -182,6 +184,8 @@ const getStatusText = (status: string) => {
SHIPPED: '已发货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退货中',
REFUNDED: '已退货',
}
return map[status] || status
}
@@ -193,10 +197,22 @@ const getStatusType = (status: string) => {
SHIPPED: 'success',
COMPLETED: 'success',
CANCELLED: 'info',
REFUNDING: 'warning',
REFUNDED: 'danger',
}
return map[status] || 'info'
}
const getOrderTypeText = (orderType: string) => {
const map: Record<string, string> = { NORMAL: '普通订单', FLASH_SALE: '秒杀订单', GROUP_BUYING: '拼团订单' }
return map[orderType] || '普通订单'
}
const getOrderTypeTag = (orderType: string) => {
const map: Record<string, string> = { NORMAL: 'info', FLASH_SALE: 'danger', GROUP_BUYING: 'success' }
return map[orderType] || 'info'
}
const loadStats = async () => {
const res = await adminApi.getOrderStats()
Object.assign(stats, res.data)

View File

@@ -81,18 +81,18 @@
</div>
<!-- 进行中的团组 -->
<div class="groups-section">
<div class="groups-section mb-8">
<h2 class="text-xl font-bold mb-4">
进行中的团组
<span class="text-sm text-gray-400 ml-2">({{ groups.length }} )</span>
<span class="text-sm text-gray-400 ml-2">({{ formingGroups.length }} )</span>
</h2>
<div v-if="groups.length === 0" class="text-center py-10">
<div v-if="formingGroups.length === 0" class="text-center py-10">
<el-empty description="暂无进行中的团组,快来开团吧!" />
</div>
<div v-else class="space-y-4">
<div v-for="group in groups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
<div v-for="group in formingGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
style="background: #fffaf2; border: 1px solid #e8e0d4">
<div class="flex items-center gap-4">
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
@@ -117,6 +117,38 @@
</div>
</div>
</div>
<!-- 已成团的团组 -->
<div v-if="completedGroups.length > 0" class="groups-section">
<h2 class="text-xl font-bold mb-4">
已成团
<span class="text-sm text-gray-400 ml-2">({{ completedGroups.length }} )</span>
</h2>
<div class="space-y-4">
<div v-for="group in completedGroups" :key="group.id" class="group-item p-4 rounded-xl flex items-center justify-between"
style="background: #f8f8f6; border: 1px solid #e8e0d4; opacity: 0.85">
<div class="flex items-center gap-4">
<el-avatar :size="40">{{ group.leaderUsername ? group.leaderUsername[0] : '?' }}</el-avatar>
<div>
<div class="font-semibold">{{ group.leaderUsername }} 的团</div>
<div class="text-sm text-green-600">
<el-icon class="mr-1"><CircleCheckFilled /></el-icon>
已成团 · {{ group.currentMembers }}/{{ group.requiredMembers }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex -space-x-2">
<el-avatar v-for="m in group.members.slice(0, 5)" :key="m.userId" :size="28" :src="m.avatar">
{{ m.username ? m.username[0] : '?' }}
</el-avatar>
</div>
<el-tag type="success" size="small">已成团</el-tag>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
@@ -126,7 +158,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import type { GroupBuying, GroupBuyingGroup } from '@/types/api'
import { groupbuyingApi } from '@/api/modules/groupbuying'
import SafeImage from '@/components/common/SafeImage.vue'
@@ -141,6 +173,9 @@ const groups = ref<GroupBuyingGroup[]>([])
const id = computed(() => Number(route.params.id))
const formingGroups = computed(() => groups.value.filter(g => g.status === 'FORMING'))
const completedGroups = computed(() => groups.value.filter(g => g.status === 'SUCCESS'))
const statusType = computed(() => {
switch (detail.value?.status) {
case 'UPCOMING': return 'warning'
@@ -175,7 +210,7 @@ const loadDetail = async () => {
const loadGroups = async () => {
try {
const res = await groupbuyingApi.getGroups(id.value, { page: 0, size: 50 })
groups.value = res.data.content.filter(g => g.status === 'FORMING')
groups.value = res.data.content
} catch (e) {
console.error('加载团组列表失败', e)
}
@@ -187,8 +222,8 @@ const handleCreateGroup = async () => {
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value })
ElMessage.success(res.data.message || '开团成功')
router.push(`/groupbuying/group/${res.data.groupId}`)
} catch (e: any) {
ElMessage.error(e.message || '开团失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}
@@ -200,8 +235,8 @@ const handleJoinGroup = async (groupId: number) => {
const res = await groupbuyingApi.joinGroup({ groupBuyingId: id.value, groupId })
ElMessage.success(res.data.message || '加入成功')
router.push(`/groupbuying/group/${res.data.groupId}`)
} catch (e: any) {
ElMessage.error(e.message || '加入失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}

View File

@@ -127,8 +127,8 @@ const handleJoin = async () => {
})
ElMessage.success(res.data.message || '加入成功')
await loadGroup()
} catch (e: any) {
ElMessage.error(e.message || '加入失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
joining.value = false
}
@@ -146,9 +146,10 @@ const handleCancel = async () => {
ElMessage.success('已退出团组')
await loadGroup()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '退出失败')
if (e !== 'cancel' && !(e instanceof Error)) {
// ElMessageBox 取消操作,忽略
}
// API 错误由全局拦截器处理
} finally {
cancelling.value = false
}

View File

@@ -55,7 +55,11 @@
<span>订单号{{ order.orderNo }}</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
<div class="flex items-center gap-2">
<el-tag v-if="order.orderType === 'FLASH_SALE'" type="danger" size="small">秒杀</el-tag>
<el-tag v-else-if="order.orderType === 'GROUP_BUYING'" type="success" size="small">拼团</el-tag>
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
</div>
</div>
<div class="p-6">

View File

@@ -3,6 +3,7 @@
@tailwind utilities;
:root {
--app-header-height: 72px;
--tone-0: #fffdf8;
--tone-50: #f7f2ea;
--tone-100: #efe7dc;

View File

@@ -47,6 +47,7 @@ export interface AdminRecentOrderRow {
quantity: number
totalAmount: number
status: string
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
createdAt: string
isFlashSale: boolean
}
@@ -81,6 +82,7 @@ export interface AdminOrderRow {
quantity: number
totalAmount: number
status: string
orderType: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
createdAt: string
isFlashSale: boolean
}

View File

@@ -113,6 +113,7 @@ export interface Order {
paymentAmount: number
paymentMethod?: string
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'COMPLETED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED'
orderType?: 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING'
items: OrderItem[]
address?: OrderAddress
remark?: string

View File

@@ -57,6 +57,14 @@ export const mapOrderStatus = (status: number | string): Order['status'] => {
return 'PENDING'
}
export const mapOrderType = (orderType: number | string | undefined, isFlashSale?: boolean): 'NORMAL' | 'FLASH_SALE' | 'GROUP_BUYING' => {
const value = typeof orderType === 'string' ? orderType : toNumber(orderType)
if (value === 'FLASH_SALE' || value === 2) return 'FLASH_SALE'
if (value === 'GROUP_BUYING' || value === 3) return 'GROUP_BUYING'
if (isFlashSale) return 'FLASH_SALE'
return 'NORMAL'
}
export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => {
const value = typeof status === 'string' ? status : toNumber(status)
if (value === 'UPCOMING' || value === 1) return 'UPCOMING'
@@ -186,6 +194,7 @@ export const normalizeOrder = (order: Record<string, any>): Order => {
paymentAmount: totalAmount,
paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'),
status,
orderType: mapOrderType(order.orderType, order.isFlashSale),
items,
address: buildOrderAddress(order),
remark: toString(order.remark),
@@ -237,6 +246,7 @@ export const normalizeAdminRecentOrder = (order: Record<string, any>): AdminRece
quantity: toNumber(order.quantity, 1),
totalAmount: toNumber(order.totalAmount ?? order.totalPrice),
status: mapOrderStatus(order.status),
orderType: mapOrderType(order.orderType, order.isFlashSale),
createdAt: toIsoLikeString(order.createdAt),
isFlashSale: Boolean(order.isFlashSale),
})
@@ -271,6 +281,7 @@ export const normalizeAdminOrder = (order: Record<string, any>): AdminOrderRow =
quantity: toNumber(order.quantity, 1),
totalAmount: toNumber(order.totalAmount),
status: mapOrderStatus(order.status),
orderType: mapOrderType(order.orderType, order.isFlashSale),
createdAt: toIsoLikeString(order.createdAt),
isFlashSale: Boolean(order.isFlashSale),
})