feat: 前端页面和组件全面完善
- 优化通用组件:导航栏、页脚、图片上传、搜索 - 完善业务组件:商品卡片、秒杀卡片 - 更新用户端页面:首页、商品、秒杀、订单、购物车、个人中心 - 新增用户收藏页面 - 完善管理后台:仪表盘、商品/订单/用户/秒杀管理 - 新增管理后台:收藏管理、评价管理、系统监控页面
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<div class="flash-sale-card card-shadow">
|
||||
<div class="relative">
|
||||
<!-- 商品图片 -->
|
||||
<img
|
||||
:src="data.productImageUrl || '/default-product.png'"
|
||||
<SafeImage
|
||||
:src="data.productImageUrl"
|
||||
:alt="data.productName"
|
||||
class="w-full h-48 object-cover"
|
||||
@error="handleImageError"
|
||||
>
|
||||
wrapper-class="w-full h-48"
|
||||
img-class="w-full h-48 object-cover"
|
||||
/>
|
||||
|
||||
<!-- 状态标签 -->
|
||||
<div class="absolute top-2 left-2">
|
||||
<el-tag :type="statusType" effect="dark" size="small">
|
||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
||||
@@ -17,61 +15,30 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 折扣标签 -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="discount-badge">
|
||||
{{ discountPercent }}% OFF
|
||||
</span>
|
||||
<span class="discount-badge">{{ discountPercent }}% OFF</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<!-- 商品名称 -->
|
||||
<h3 class="font-semibold text-lg mb-2 truncate">{{ data.productName }}</h3>
|
||||
|
||||
<!-- 价格 -->
|
||||
<div class="flex items-end mb-3">
|
||||
<span class="text-2xl font-bold text-red-500">¥{{ data.flashPrice }}</span>
|
||||
<span class="ml-2 text-sm text-gray-400 line-through">¥{{ data.originalPrice }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 库存进度 -->
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>剩余: {{ data.remainingStock }}件</span>
|
||||
<span>{{ stockPercent }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="stockPercent"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:color="progressColor"
|
||||
/>
|
||||
<el-progress :percentage="stockPercent" :stroke-width="6" :show-text="false" :color="progressColor" />
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div class="text-center mb-3">
|
||||
<CountDown
|
||||
v-if="data.status === 'ACTIVE'"
|
||||
:end-time="endTime"
|
||||
@finish="$emit('refresh')"
|
||||
/>
|
||||
<span v-else-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">
|
||||
即将开始
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">
|
||||
已结束
|
||||
</span>
|
||||
<CountDown v-if="data.status === 'ACTIVE'" :end-time="endTime" @finish="$emit('refresh')" />
|
||||
<span v-else-if="data.status === 'UPCOMING'" class="text-sm text-gray-500">即将开始</span>
|
||||
<span v-else class="text-sm text-gray-400">已结束</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-button
|
||||
type="danger"
|
||||
class="w-full"
|
||||
:disabled="!canParticipate"
|
||||
:loading="loading"
|
||||
@click="handleParticipate"
|
||||
>
|
||||
<el-button type="danger" class="w-full" :disabled="!canParticipate" :loading="loading" @click="handleParticipate">
|
||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
@@ -83,19 +50,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FlashSale } from '@/types/api'
|
||||
import CountDown from './CountDown.vue'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: FlashSale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
participate: [id: number]
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const props = defineProps<{ data: FlashSale }>()
|
||||
const emit = defineEmits<{ participate: [id: number]; refresh: [] }>()
|
||||
const loading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const statusType = computed(() => {
|
||||
switch (props.data.status) {
|
||||
case 'UPCOMING': return 'warning'
|
||||
@@ -114,29 +74,11 @@ const statusText = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const discountPercent = computed(() => {
|
||||
return Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100)
|
||||
})
|
||||
|
||||
const stockPercent = computed(() => {
|
||||
if (props.data.flashStock === 0) return 0
|
||||
return Math.round(props.data.remainingStock / props.data.flashStock * 100)
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
if (stockPercent.value > 50) return '#67c23a'
|
||||
if (stockPercent.value > 20) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
})
|
||||
|
||||
const endTime = computed(() => {
|
||||
return new Date(props.data.endTime).getTime()
|
||||
})
|
||||
|
||||
const canParticipate = computed(() => {
|
||||
return props.data.status === 'ACTIVE' && props.data.remainingStock > 0
|
||||
})
|
||||
|
||||
const discountPercent = computed(() => Math.round((1 - props.data.flashPrice / props.data.originalPrice) * 100))
|
||||
const stockPercent = computed(() => props.data.flashStock === 0 ? 0 : Math.round(props.data.remainingStock / props.data.flashStock * 100))
|
||||
const progressColor = computed(() => stockPercent.value > 50 ? '#67c23a' : stockPercent.value > 20 ? '#e6a23c' : '#f56c6c')
|
||||
const endTime = computed(() => new Date(props.data.endTime).getTime())
|
||||
const canParticipate = computed(() => props.data.status === 'ACTIVE' && props.data.remainingStock > 0)
|
||||
const buttonText = computed(() => {
|
||||
if (props.data.status === 'UPCOMING') return '即将开始'
|
||||
if (props.data.status === 'ENDED') return '已结束'
|
||||
@@ -144,22 +86,11 @@ const buttonText = computed(() => {
|
||||
return '立即抢购'
|
||||
})
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
// 参与秒杀
|
||||
const handleParticipate = async () => {
|
||||
if (!canParticipate.value) return
|
||||
|
||||
loading.value = true
|
||||
emit('participate', props.data.id)
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
setTimeout(() => { loading.value = false }, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<div class="product-card card-shadow">
|
||||
<!-- 商品图片 -->
|
||||
<div class="relative overflow-hidden h-48">
|
||||
<img
|
||||
:src="data.imageUrl || '/default-product.png'"
|
||||
<SafeImage
|
||||
:src="data.imageUrl"
|
||||
:alt="data.name"
|
||||
class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||
@error="handleImageError"
|
||||
>
|
||||
wrapper-class="w-full h-full"
|
||||
img-class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
|
||||
<!-- 库存标签 -->
|
||||
<div v-if="data.stock <= 10" class="absolute top-2 right-2">
|
||||
<el-tag type="warning" size="small">
|
||||
仅剩 {{ data.stock }} 件
|
||||
@@ -18,36 +16,20 @@
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<!-- 商品名称 -->
|
||||
<h3 class="font-semibold text-base mb-2 truncate">{{ data.name }}</h3>
|
||||
|
||||
<!-- 商品描述 -->
|
||||
<p class="text-sm text-gray-500 mb-3 line-clamp-2">
|
||||
{{ data.description || '暂无描述' }}
|
||||
</p>
|
||||
|
||||
<!-- 价格和库存 -->
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-xl font-bold text-primary-500">¥{{ data.price }}</span>
|
||||
<span class="text-sm text-gray-400">库存: {{ data.stock }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
:disabled="data.stock === 0"
|
||||
@click="handleAddToCart"
|
||||
>
|
||||
<el-button type="primary" size="small" class="flex-1" :disabled="data.stock === 0" @click="handleAddToCart">
|
||||
<el-icon class="mr-1"><ShoppingCart /></el-icon>
|
||||
加入购物车
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleViewDetail"
|
||||
>
|
||||
<el-button size="small" @click="handleViewDetail">
|
||||
<el-icon><View /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -58,31 +40,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Product } from '@/types/api'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: Product
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
addToCart: [id: number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{ data: Product }>()
|
||||
const emit = defineEmits<{ addToCart: [id: number] }>()
|
||||
const router = useRouter()
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
// 添加到购物车
|
||||
const handleAddToCart = () => {
|
||||
if (props.data.stock > 0) {
|
||||
emit('addToCart', props.data.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = () => {
|
||||
router.push(`/product/${props.data.id}`)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/flashsale" class="text-gray-600 hover:text-primary-500">
|
||||
<router-link to="/flashsales" class="text-gray-600 hover:text-primary-500">
|
||||
秒杀活动
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
首页
|
||||
</router-link>
|
||||
<router-link to="/flashsale" class="nav-link">
|
||||
<router-link to="/flashsales" class="nav-link">
|
||||
<el-icon><Lightning /></el-icon>
|
||||
秒杀活动
|
||||
</router-link>
|
||||
@@ -33,19 +33,7 @@
|
||||
|
||||
<!-- 右侧菜单 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索商品"
|
||||
class="w-48"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="cursor-pointer" @click="handleSearch">
|
||||
<Search />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<SearchComponent />
|
||||
|
||||
<!-- 通知中心 -->
|
||||
<NotificationCenter v-if="userStore.isLoggedIn" />
|
||||
@@ -76,6 +64,10 @@
|
||||
<el-icon><List /></el-icon>
|
||||
我的订单
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="router.push('/favorites')">
|
||||
<el-icon><Star /></el-icon>
|
||||
我的收藏
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="userStore.isAdmin" @click="router.push('/admin')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
管理后台
|
||||
@@ -106,25 +98,15 @@ import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import NotificationCenter from './NotificationCenter.vue'
|
||||
import SearchComponent from './SearchComponent.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const cartCount = ref(0)
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({
|
||||
path: '/products',
|
||||
query: { keyword: searchKeyword.value }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
@@ -133,7 +115,7 @@ const handleLogout = async () => {
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
userStore.logout()
|
||||
await userStore.logout()
|
||||
}
|
||||
|
||||
// 更新购物车数量
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { resolveImageUrl } from '@/utils/image'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { UploadFile, UploadFiles, UploadRawFile } from 'element-plus'
|
||||
|
||||
@@ -75,6 +76,7 @@ interface Props {
|
||||
accept?: string
|
||||
maxSize?: number // MB
|
||||
aspectRatio?: number // 宽高比
|
||||
action?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -82,6 +84,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
accept: 'image/jpeg,image/jpg,image/png,image/gif,image/webp',
|
||||
maxSize: 5, // 5MB
|
||||
action: '/api/admin/products/upload-image',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -92,7 +95,7 @@ const emit = defineEmits<{
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 上传相关
|
||||
const uploadUrl = computed(() => `${import.meta.env.VITE_API_BASE_URL}/api/upload/image`)
|
||||
const uploadUrl = computed(() => `${import.meta.env.VITE_API_BASE_URL || ''}${props.action}`)
|
||||
const headers = computed(() => ({
|
||||
Authorization: `Bearer ${userStore.token}`
|
||||
}))
|
||||
@@ -111,14 +114,14 @@ watch(
|
||||
if (Array.isArray(val)) {
|
||||
fileList.value = val.map((url, index) => ({
|
||||
name: `image-${index}`,
|
||||
url,
|
||||
url: resolveImageUrl(url),
|
||||
status: 'success',
|
||||
uid: Date.now() + index
|
||||
} as UploadFile))
|
||||
} else {
|
||||
fileList.value = [{
|
||||
name: 'image',
|
||||
url: val,
|
||||
url: resolveImageUrl(val),
|
||||
status: 'success',
|
||||
uid: Date.now()
|
||||
} as UploadFile]
|
||||
@@ -184,7 +187,7 @@ const beforeUpload = (rawFile: UploadRawFile) => {
|
||||
// 上传成功
|
||||
const handleSuccess = (response: any, file: UploadFile, files: UploadFiles) => {
|
||||
if (response.success) {
|
||||
file.url = response.data.url
|
||||
file.url = resolveImageUrl(response.data?.url || response.imageUrl || response.data?.imageUrl)
|
||||
updateValue()
|
||||
ElMessage.success('上传成功')
|
||||
} else {
|
||||
|
||||
@@ -111,9 +111,7 @@
|
||||
<el-form-item label="商品分类">
|
||||
<el-select v-model="advancedForm.category" placeholder="选择分类">
|
||||
<el-option label="全部分类" value="" />
|
||||
<el-option label="电子产品" value="electronics" />
|
||||
<el-option label="服装鞋包" value="fashion" />
|
||||
<el-option label="食品饮料" value="food" />
|
||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
||||
<el-option label="图书音像" value="books" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -157,8 +155,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -181,6 +180,7 @@ const hotSearches = ref([
|
||||
|
||||
// 搜索建议
|
||||
const suggestions = ref<any[]>([])
|
||||
const categories = ref<string[]>([])
|
||||
|
||||
// 高级搜索表单
|
||||
const advancedForm = reactive({
|
||||
@@ -260,27 +260,37 @@ const fetchSuggestions = debounce(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟搜索建议
|
||||
suggestions.value = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'product',
|
||||
name: `${searchQuery.value} Pro Max`,
|
||||
price: 8999
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'product',
|
||||
name: `${searchQuery.value} 标准版`,
|
||||
price: 5999
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'flashsale',
|
||||
name: `${searchQuery.value} 限时秒杀`,
|
||||
price: 4999
|
||||
}
|
||||
]
|
||||
try {
|
||||
const [productRes, flashSaleRes] = await Promise.all([
|
||||
productApi.getList({ keyword: searchQuery.value, page: 0, size: 5 }),
|
||||
flashsaleApi.getList({ page: 0, size: 6 }),
|
||||
])
|
||||
|
||||
const productSuggestions = productRes.success
|
||||
? productRes.data.content.map((item) => ({
|
||||
id: item.id,
|
||||
type: 'product',
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
}))
|
||||
: []
|
||||
|
||||
const flashSaleSuggestions = flashSaleRes.success
|
||||
? flashSaleRes.data.content
|
||||
.filter((item) => item.productName.includes(searchQuery.value))
|
||||
.slice(0, 3)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
type: 'flashsale',
|
||||
name: item.productName,
|
||||
price: item.flashPrice,
|
||||
}))
|
||||
: []
|
||||
|
||||
suggestions.value = [...productSuggestions, ...flashSaleSuggestions].slice(0, 8)
|
||||
} catch (error) {
|
||||
suggestions.value = []
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 快速搜索
|
||||
@@ -337,8 +347,10 @@ watch(searchQuery, () => {
|
||||
fetchSuggestions()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadSearchHistory()
|
||||
const res = await productApi.getCategories()
|
||||
categories.value = res.success ? res.data : []
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>用户管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/reviews">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<template #title>评价管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/favorites">
|
||||
<el-icon><Star /></el-icon>
|
||||
<template #title>收藏管理</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/monitor">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<template #title>系统监控</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
@@ -140,6 +155,9 @@ const currentPageTitle = computed(() => {
|
||||
'/admin/flashsales': '秒杀管理',
|
||||
'/admin/orders': '订单管理',
|
||||
'/admin/users': '用户管理',
|
||||
'/admin/reviews': '评价管理',
|
||||
'/admin/favorites': '收藏管理',
|
||||
'/admin/monitor': '系统监控',
|
||||
}
|
||||
return titles[route.path] || ''
|
||||
})
|
||||
@@ -166,7 +184,7 @@ const handleLogout = async () => {
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
userStore.logout()
|
||||
await userStore.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,137 +1,112 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-blue-100">
|
||||
<el-icon :size="24" class="text-blue-500"><User /></el-icon>
|
||||
<div class="stat-icon bg-blue-100 text-blue-500">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div>
|
||||
<div class="stat-value">{{ statistics.totalUsers }}</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
<div class="stat-trend text-green-500">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
12% 较昨日
|
||||
</div>
|
||||
<div class="stat-desc">在线 {{ userStats.onlineUsers }} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-green-100">
|
||||
<el-icon :size="24" class="text-green-500"><ShoppingBag /></el-icon>
|
||||
<div class="stat-icon bg-emerald-100 text-emerald-500">
|
||||
<el-icon><ShoppingBag /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div>
|
||||
<div class="stat-value">{{ statistics.totalProducts }}</div>
|
||||
<div class="stat-label">商品总数</div>
|
||||
<div class="stat-trend text-green-500">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
5% 较昨日
|
||||
</div>
|
||||
<div class="stat-desc">低库存 {{ productStats.lowStockProducts }} 件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-orange-100">
|
||||
<el-icon :size="24" class="text-orange-500"><List /></el-icon>
|
||||
<div class="stat-icon bg-orange-100 text-orange-500">
|
||||
<el-icon><List /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div>
|
||||
<div class="stat-value">{{ statistics.totalOrders }}</div>
|
||||
<div class="stat-label">订单总数</div>
|
||||
<div class="stat-trend text-green-500">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
23% 较昨日
|
||||
</div>
|
||||
<div class="stat-desc">今日新增 {{ statistics.todayOrders }} 单</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-red-100">
|
||||
<el-icon :size="24" class="text-red-500"><Coin /></el-icon>
|
||||
<div class="stat-icon bg-rose-100 text-rose-500">
|
||||
<el-icon><Coin /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ statistics.totalSales.toFixed(2) }}</div>
|
||||
<div class="stat-label">销售总额</div>
|
||||
<div class="stat-trend text-green-500">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
18% 较昨日
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">¥{{ formatCurrency(statistics.totalAmount) }}</div>
|
||||
<div class="stat-label">累计成交额</div>
|
||||
<div class="stat-desc">活动中 {{ statistics.activeFlashSales }} 场</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 销售趋势 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">销售趋势</h3>
|
||||
<el-radio-group v-model="salesPeriod" size="small">
|
||||
<el-radio-button label="week">本周</el-radio-button>
|
||||
<el-radio-button label="month">本月</el-radio-button>
|
||||
<el-radio-button label="year">本年</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">订单趋势</h3>
|
||||
<p class="panel-subtitle">最近订单金额分布</p>
|
||||
</div>
|
||||
<el-button text type="primary" @click="loadDashboardData">刷新</el-button>
|
||||
</div>
|
||||
<div ref="salesChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- 商品分类分布 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">商品分类分布</h3>
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">商品状态占比</h3>
|
||||
<p class="panel-subtitle">沿用 JSP 仪表盘的结构化总览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="categoryChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 最新订单 -->
|
||||
<div class="table-card">
|
||||
<div class="table-header">
|
||||
<h3 class="table-title">最新订单</h3>
|
||||
<el-button text type="primary" @click="router.push('/admin/orders')">
|
||||
查看全部 <el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">最新订单</h3>
|
||||
<p class="panel-subtitle">映射 JSP 仪表盘最近订单表格</p>
|
||||
</div>
|
||||
<el-button text type="primary" @click="router.push('/admin/orders')">查看全部</el-button>
|
||||
</div>
|
||||
<el-table :data="recentOrders" stripe>
|
||||
<el-table-column prop="orderNo" label="订单号" width="120" />
|
||||
<el-table-column prop="username" label="用户" />
|
||||
<el-table-column prop="totalAmount" label="金额">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.totalAmount }}
|
||||
</template>
|
||||
<el-table v-loading="loading" :data="recentOrders" stripe>
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="120" />
|
||||
<el-table-column prop="username" label="用户" min-width="100" />
|
||||
<el-table-column prop="productName" label="商品" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="totalAmount" label="金额" min-width="100">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态">
|
||||
<el-table-column prop="status" label="状态" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getOrderStatusType(row.status)" size="small">
|
||||
{{ getOrderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag :type="getOrderStatusType(row.status)">{{ getOrderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 热门商品 -->
|
||||
<div class="table-card">
|
||||
<div class="table-header">
|
||||
<h3 class="table-title">热门商品</h3>
|
||||
<el-button text type="primary" @click="router.push('/admin/products')">
|
||||
查看全部 <el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">热门商品</h3>
|
||||
<p class="panel-subtitle">对齐 JSP 后台热门商品模块</p>
|
||||
</div>
|
||||
<el-button text type="primary" @click="router.push('/admin/products')">查看全部</el-button>
|
||||
</div>
|
||||
<el-table :data="hotProducts" stripe>
|
||||
<el-table-column prop="name" label="商品名称" />
|
||||
<el-table-column prop="price" label="价格">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.price }}
|
||||
</template>
|
||||
<el-table v-loading="loading" :data="hotProducts" stripe>
|
||||
<el-table-column prop="name" label="商品名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="price" label="价格" min-width="100">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sales" label="销量" />
|
||||
<el-table-column prop="stock" label="库存">
|
||||
<el-table-column prop="sales" label="销量" min-width="80" />
|
||||
<el-table-column prop="stock" label="库存" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.stock > 10 ? 'success' : 'warning'" size="small">
|
||||
{{ row.stock }}
|
||||
</el-tag>
|
||||
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -141,182 +116,165 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type {
|
||||
AdminDashboardStats,
|
||||
AdminHotProductRow,
|
||||
AdminProductStats,
|
||||
AdminRecentOrderRow,
|
||||
AdminUserStats,
|
||||
} from '@/types/admin'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
// 统计数据
|
||||
const statistics = reactive({
|
||||
totalUsers: 5234,
|
||||
totalProducts: 128,
|
||||
totalOrders: 1893,
|
||||
totalSales: 285670.50,
|
||||
todayOrders: 67,
|
||||
todaySales: 12450.00,
|
||||
activeFlashSales: 5,
|
||||
onlineUsers: 342
|
||||
const statistics = reactive<AdminDashboardStats>({
|
||||
totalUsers: 0,
|
||||
totalProducts: 0,
|
||||
totalOrders: 0,
|
||||
totalAmount: 0,
|
||||
todayOrders: 0,
|
||||
paidOrders: 0,
|
||||
pendingOrders: 0,
|
||||
activeFlashSales: 0,
|
||||
})
|
||||
|
||||
// 图表相关
|
||||
const salesPeriod = ref('week')
|
||||
const salesChartRef = ref<HTMLElement>()
|
||||
const categoryChartRef = ref<HTMLElement>()
|
||||
const userStats = reactive<AdminUserStats>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
newUsers: 0,
|
||||
onlineUsers: 0,
|
||||
})
|
||||
|
||||
const productStats = reactive<AdminProductStats>({
|
||||
totalProducts: 0,
|
||||
activeProducts: 0,
|
||||
inactiveProducts: 0,
|
||||
lowStockProducts: 0,
|
||||
})
|
||||
|
||||
const recentOrders = ref<AdminRecentOrderRow[]>([])
|
||||
const hotProducts = ref<AdminHotProductRow[]>([])
|
||||
|
||||
const salesChartRef = ref<HTMLElement | null>(null)
|
||||
const categoryChartRef = ref<HTMLElement | null>(null)
|
||||
let salesChart: echarts.ECharts | null = null
|
||||
let categoryChart: echarts.ECharts | null = null
|
||||
|
||||
// 最新订单
|
||||
const recentOrders = ref([
|
||||
{ id: 1, orderNo: 'ORD2024001', username: 'user1', totalAmount: 299.00, status: 'PAID' },
|
||||
{ id: 2, orderNo: 'ORD2024002', username: 'user2', totalAmount: 599.00, status: 'SHIPPED' },
|
||||
{ id: 3, orderNo: 'ORD2024003', username: 'user3', totalAmount: 199.00, status: 'PENDING' },
|
||||
{ id: 4, orderNo: 'ORD2024004', username: 'user4', totalAmount: 899.00, status: 'COMPLETED' },
|
||||
{ id: 5, orderNo: 'ORD2024005', username: 'user5', totalAmount: 399.00, status: 'PAID' },
|
||||
])
|
||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||
|
||||
// 热门商品
|
||||
const hotProducts = ref([
|
||||
{ id: 1, name: 'iPhone 15 Pro', price: 8999, sales: 234, stock: 45 },
|
||||
{ id: 2, name: 'MacBook Pro', price: 12999, sales: 156, stock: 23 },
|
||||
{ id: 3, name: 'AirPods Pro', price: 1999, sales: 567, stock: 89 },
|
||||
{ id: 4, name: 'iPad Pro', price: 6999, sales: 123, stock: 8 },
|
||||
{ id: 5, name: 'Apple Watch', price: 2999, sales: 345, stock: 67 },
|
||||
])
|
||||
|
||||
// 获取订单状态类型
|
||||
const getOrderStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': 'warning',
|
||||
'PAID': 'primary',
|
||||
'SHIPPED': 'primary',
|
||||
'COMPLETED': 'success',
|
||||
'CANCELLED': 'info'
|
||||
PENDING: 'warning',
|
||||
PAID: 'primary',
|
||||
SHIPPED: 'success',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取订单状态文本
|
||||
const getOrderStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待付款',
|
||||
'PAID': '待发货',
|
||||
'SHIPPED': '待收货',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消'
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
SHIPPED: '已发货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 初始化销售趋势图表
|
||||
const initSalesChart = () => {
|
||||
const renderSalesChart = () => {
|
||||
if (!salesChartRef.value) return
|
||||
if (!salesChart) {
|
||||
salesChart = echarts.init(salesChartRef.value)
|
||||
}
|
||||
|
||||
salesChart = echarts.init(salesChartRef.value)
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
salesChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 24, right: 12, top: 24, bottom: 24, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
data: recentOrders.value.map((item) => item.orderNo),
|
||||
axisLabel: { rotate: 30 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}'
|
||||
}
|
||||
axisLabel: { formatter: '¥{value}' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '销售额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [12000, 15000, 13000, 18000, 22000, 25000, 20000],
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
|
||||
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
|
||||
])
|
||||
},
|
||||
type: 'bar',
|
||||
data: recentOrders.value.map((item) => item.totalAmount),
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
salesChart.setOption(option)
|
||||
borderRadius: [6, 6, 0, 0],
|
||||
color: '#3b82f6',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化分类分布图表
|
||||
const initCategoryChart = () => {
|
||||
const renderCategoryChart = () => {
|
||||
if (!categoryChartRef.value) return
|
||||
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '商品分类',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 35, name: '电子产品' },
|
||||
{ value: 28, name: '服装鞋包' },
|
||||
{ value: 20, name: '食品饮料' },
|
||||
{ value: 10, name: '图书音像' },
|
||||
{ value: 7, name: '其他' }
|
||||
]
|
||||
}
|
||||
]
|
||||
if (!categoryChart) {
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
}
|
||||
|
||||
categoryChart.setOption(option)
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: '商品状态',
|
||||
type: 'pie',
|
||||
radius: ['42%', '72%'],
|
||||
data: [
|
||||
{ value: productStats.activeProducts, name: '上架商品' },
|
||||
{ value: productStats.inactiveProducts, name: '下架商品' },
|
||||
{ value: productStats.lowStockProducts, name: '低库存商品' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [dashboardRes, userRes, productRes, recentRes, hotRes] = await Promise.all([
|
||||
adminApi.getDashboardStats(),
|
||||
adminApi.getUserStats(),
|
||||
adminApi.getProductStats(),
|
||||
adminApi.getRecentOrders(6),
|
||||
adminApi.getHotProducts(6),
|
||||
])
|
||||
|
||||
Object.assign(statistics, dashboardRes.data)
|
||||
Object.assign(userStats, userRes.data)
|
||||
Object.assign(productStats, productRes.data)
|
||||
recentOrders.value = recentRes.data
|
||||
hotProducts.value = hotRes.data
|
||||
|
||||
await nextTick()
|
||||
renderSalesChart()
|
||||
renderCategoryChart()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小变化处理
|
||||
const handleResize = () => {
|
||||
salesChart?.resize()
|
||||
categoryChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initSalesChart()
|
||||
initCategoryChart()
|
||||
})
|
||||
|
||||
loadDashboardData()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
@@ -330,53 +288,43 @@ onUnmounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.admin-dashboard {
|
||||
.stat-card {
|
||||
@apply bg-white rounded-lg p-6 shadow-sm flex items-center gap-4;
|
||||
|
||||
.stat-icon {
|
||||
@apply w-12 h-12 rounded-lg flex items-center justify-center;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@apply flex-1;
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold text-gray-800;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm text-gray-500 mt-1;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
@apply text-sm mt-2 flex items-center gap-1;
|
||||
}
|
||||
}
|
||||
@apply bg-white rounded-xl p-5 shadow-sm flex items-center gap-4;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.table-card {
|
||||
@apply bg-white rounded-lg shadow-sm;
|
||||
|
||||
.chart-header,
|
||||
.table-header {
|
||||
@apply p-4 border-b flex justify-between items-center;
|
||||
|
||||
.chart-title,
|
||||
.table-title {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
@apply p-4;
|
||||
height: 300px;
|
||||
}
|
||||
.stat-icon {
|
||||
@apply w-12 h-12 rounded-xl flex items-center justify-center text-xl;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
:deep(.el-table) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
@apply text-xs text-slate-400 mt-2;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex items-center justify-between mb-4 gap-4;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-lg font-semibold text-slate-900;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
106
flash-sale-frontend/src/pages/admin/favorites.vue
Normal file
106
flash-sale-frontend/src/pages/admin/favorites.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">收藏管理</h2>
|
||||
<p class="page-subtitle">查看用户收藏关系并支持后台删除</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button @click="reloadData"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue"><div class="mini-stat__value">{{ stats.totalFavorites }}</div><div class="mini-stat__label">收藏总数</div></div>
|
||||
<div class="mini-stat green"><div class="mini-stat__value">{{ stats.favoriteUsers }}</div><div class="mini-stat__label">收藏用户数</div></div>
|
||||
<div class="mini-stat orange"><div class="mini-stat__value">{{ stats.favoriteProducts }}</div><div class="mini-stat__label">被收藏商品数</div></div>
|
||||
<div class="mini-stat purple"><div class="mini-stat__value">{{ stats.todayFavorites }}</div><div class="mini-stat__label">今日新增收藏</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品" @keyup.enter="loadFavorites" />
|
||||
<el-button type="primary" @click="loadFavorites">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="favorites" stripe>
|
||||
<el-table-column prop="productName" label="商品" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="productCategory" label="分类" width="120" />
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="createdAt" label="收藏时间" min-width="170" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }"><el-button text type="danger" @click="removeFavorite(row.id)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="loadFavorites" @size-change="loadFavorites" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type { AdminFavoriteRow, AdminFavoriteStats } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const total = ref(0)
|
||||
const favorites = ref<AdminFavoriteRow[]>([])
|
||||
const stats = reactive<AdminFavoriteStats>({ totalFavorites: 0, favoriteUsers: 0, favoriteProducts: 0, todayFavorites: 0 })
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getFavoriteStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
|
||||
const loadFavorites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getFavorites({ page: page.value, size: size.value, keyword: keyword.value || undefined })
|
||||
favorites.value = res.data.favorites
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeFavorite = async (id: number) => {
|
||||
await ElMessageBox.confirm('确定删除该收藏记录吗?', '提示', { type: 'warning' })
|
||||
await adminApi.deleteFavorite(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadStats(); loadFavorites()
|
||||
}
|
||||
|
||||
const migrateItems = async () => {
|
||||
const res = await adminApi.migrateLegacyOrderItems()
|
||||
ElMessage.success(`迁移完成:迁移 ${res.data.migrated} 条,跳过 ${res.data.skipped} 条`)
|
||||
}
|
||||
|
||||
const reloadData = async () => { await Promise.all([loadStats(), loadFavorites()]) }
|
||||
onMounted(() => { reloadData() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { display:flex; flex-direction:column; gap:20px; }
|
||||
.page-header { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
|
||||
.page-title { @apply text-2xl font-bold text-slate-900; }
|
||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||
.actions { display:flex; gap:12px; }
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
||||
.mini-stat.blue { background:linear-gradient(135deg,#3b82f6,#2563eb); }
|
||||
.mini-stat.green { background:linear-gradient(135deg,#10b981,#059669); }
|
||||
.mini-stat.orange { background:linear-gradient(135deg,#f59e0b,#ea580c); }
|
||||
.mini-stat.purple { background:linear-gradient(135deg,#8b5cf6,#7c3aed); }
|
||||
.mini-stat__value { @apply text-3xl font-bold; }
|
||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||
.panel-card { @apply bg-white rounded-xl shadow-sm p-5; }
|
||||
.filter-card { display:grid; grid-template-columns:1fr 100px; gap:12px; }
|
||||
.table-footer { @apply flex justify-end mt-4; }
|
||||
</style>
|
||||
@@ -1,48 +1,533 @@
|
||||
<template>
|
||||
<div class="admin-flashsales">
|
||||
<div class="admin-flashsales page-shell">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">秒杀活动管理</h2>
|
||||
<el-button type="primary">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
创建秒杀
|
||||
</el-button>
|
||||
<div>
|
||||
<h2 class="page-title">秒杀管理</h2>
|
||||
<p class="page-subtitle">覆盖 JSP 的活动列表、发布、暂停、恢复、结束与详情查看</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="reloadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建秒杀
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<el-table :data="[]" stripe>
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat purple">
|
||||
<div class="mini-stat__value">{{ stats.totalFlashSales }}</div>
|
||||
<div class="mini-stat__label">活动总数</div>
|
||||
</div>
|
||||
<div class="mini-stat red">
|
||||
<div class="mini-stat__value">{{ stats.activeFlashSales }}</div>
|
||||
<div class="mini-stat__label">进行中</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ stats.upcomingFlashSales }}</div>
|
||||
<div class="mini-stat__label">即将开始</div>
|
||||
</div>
|
||||
<div class="mini-stat gray">
|
||||
<div class="mini-stat__value">{{ stats.endedFlashSales }}</div>
|
||||
<div class="mini-stat__label">已结束</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input v-model="query.keyword" clearable placeholder="搜索商品名称" @keyup.enter="loadFlashSales">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||
<el-option label="即将开始" value="UPCOMING" />
|
||||
<el-option label="进行中" value="ACTIVE" />
|
||||
<el-option label="已结束" value="ENDED" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="displayFlashSales" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="productName" label="商品名称" />
|
||||
<el-table-column prop="flashPrice" label="秒杀价" />
|
||||
<el-table-column prop="flashStock" label="秒杀库存" />
|
||||
<el-table-column prop="startTime" label="开始时间" />
|
||||
<el-table-column prop="endTime" label="结束时间" />
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default>
|
||||
<el-button text type="primary" size="small">编辑</el-button>
|
||||
<el-button text type="danger" size="small">删除</el-button>
|
||||
<el-table-column label="商品" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<SafeImage :src="row.productImageUrl" :alt="row.productName" wrapper-class="product-image" img-class="product-image" />
|
||||
<div>
|
||||
<div class="product-name">{{ row.productName }}</div>
|
||||
<div class="product-meta">商品ID:{{ row.productId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="originalPrice" label="原价" width="110">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.originalPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flashPrice" label="秒杀价" width="110">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.flashPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flashStock" label="总库存" width="100" />
|
||||
<el-table-column prop="remainingStock" label="剩余库存" width="100" />
|
||||
<el-table-column label="时间范围" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatTime(row.startTime) }}</div>
|
||||
<div class="text-slate-400">至 {{ formatTime(row.endTime) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button text type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 'UPCOMING'" text type="success" @click="changeStatus('publish', row)">发布</el-button>
|
||||
<el-button v-if="row.status === 'ACTIVE'" text type="warning" @click="changeStatus('pause', row)">暂停</el-button>
|
||||
<el-button v-if="row.status === 'ENDED'" text type="success" @click="changeStatus('resume', row)">恢复</el-button>
|
||||
<el-button v-if="row.status !== 'ENDED'" text type="danger" @click="changeStatus('end', row)">结束</el-button>
|
||||
<el-button text type="danger" @click="removeFlashSale(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadFlashSales"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '创建秒杀活动' : '编辑秒杀活动'" width="760px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="关联商品" prop="productId">
|
||||
<el-select v-model="form.productId" filterable :disabled="formMode === 'edit'" placeholder="请选择商品">
|
||||
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="秒杀价格" prop="flashPrice">
|
||||
<el-input-number v-model="form.flashPrice" :min="0.01" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="秒杀库存" prop="flashStock">
|
||||
<el-input-number v-model="form.flashStock" :min="1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="秒杀详情" width="760px">
|
||||
<div v-if="currentItem" class="detail-layout">
|
||||
<SafeImage :src="currentItem.productImageUrl" :alt="currentItem.productName" wrapper-class="detail-image" img-class="detail-image" />
|
||||
<div class="detail-content">
|
||||
<h3>{{ currentItem.productName }}</h3>
|
||||
<div class="price-line">
|
||||
<span class="flash-price">¥{{ formatCurrency(currentItem.flashPrice) }}</span>
|
||||
<span class="origin-price">¥{{ formatCurrency(currentItem.originalPrice) }}</span>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div><span>活动状态:</span>{{ getStatusText(currentItem.status) }}</div>
|
||||
<div><span>总库存:</span>{{ currentItem.flashStock }}</div>
|
||||
<div><span>剩余库存:</span>{{ currentItem.remainingStock }}</div>
|
||||
<div><span>限购:</span>{{ currentItem.limitPerUser }} 件</div>
|
||||
<div><span>开始时间:</span>{{ formatTime(currentItem.startTime) }}</div>
|
||||
<div><span>结束时间:</span>{{ formatTime(currentItem.endTime) }}</div>
|
||||
</div>
|
||||
<el-progress :percentage="getStockRate(currentItem)" :stroke-width="10" class="mt-5" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type { FlashSale } from '@/types/api'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import type { AdminFlashSaleStats, AdminProductRow } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const flashSales = ref<FlashSale[]>([])
|
||||
const currentItem = ref<FlashSale | null>(null)
|
||||
const productOptions = ref<AdminProductRow[]>([])
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const stats = reactive<AdminFlashSaleStats>({
|
||||
totalFlashSales: 0,
|
||||
activeFlashSales: 0,
|
||||
upcomingFlashSales: 0,
|
||||
endedFlashSales: 0,
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
productId: undefined as number | undefined,
|
||||
flashPrice: 0.01,
|
||||
flashStock: 1,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
productId: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
||||
flashPrice: [{ required: true, message: '请输入秒杀价格', trigger: 'change' }],
|
||||
flashStock: [{ required: true, message: '请输入秒杀库存', trigger: 'change' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const displayFlashSales = computed(() => {
|
||||
if (!query.keyword) return flashSales.value
|
||||
return flashSales.value.filter((item) => item.productName.toLowerCase().includes(query.keyword.toLowerCase()))
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
UPCOMING: '即将开始',
|
||||
ACTIVE: '进行中',
|
||||
ENDED: '已结束',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
UPCOMING: 'warning',
|
||||
ACTIVE: 'danger',
|
||||
ENDED: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStockRate = (item: FlashSale) => {
|
||||
if (!item.flashStock) return 0
|
||||
return Math.round((item.remainingStock / item.flashStock) * 100)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0
|
||||
form.productId = undefined
|
||||
form.flashPrice = 0.01
|
||||
form.flashStock = 1
|
||||
form.startTime = ''
|
||||
form.endTime = ''
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getFlashSaleStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
const res = await adminApi.getProducts({ page: 1, size: 100 })
|
||||
productOptions.value = res.data.products.filter((item) => item.status === 1)
|
||||
}
|
||||
|
||||
const loadFlashSales = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await flashsaleApi.getList({
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
status: query.status || undefined,
|
||||
})
|
||||
flashSales.value = res.data.content
|
||||
pagination.total = res.data.totalElements
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetForm()
|
||||
formMode.value = 'create'
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (row: FlashSale) => {
|
||||
formMode.value = 'edit'
|
||||
form.id = row.id
|
||||
form.productId = row.productId
|
||||
form.flashPrice = row.flashPrice
|
||||
form.flashStock = row.flashStock
|
||||
form.startTime = row.startTime
|
||||
form.endTime = row.endTime
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
const openDetail = (row: FlashSale) => {
|
||||
currentItem.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
productId: form.productId!,
|
||||
flashPrice: form.flashPrice,
|
||||
flashStock: form.flashStock,
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
}
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
await flashsaleApi.create(payload)
|
||||
ElMessage.success('秒杀活动创建成功')
|
||||
} else {
|
||||
await flashsaleApi.update(form.id, {
|
||||
flashPrice: form.flashPrice,
|
||||
flashStock: form.flashStock,
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
})
|
||||
ElMessage.success('秒杀活动更新成功')
|
||||
}
|
||||
|
||||
formVisible.value = false
|
||||
await reloadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const changeStatus = async (action: 'publish' | 'pause' | 'resume' | 'end', row: FlashSale) => {
|
||||
const actionTextMap = {
|
||||
publish: '发布',
|
||||
pause: '暂停',
|
||||
resume: '恢复',
|
||||
end: '结束',
|
||||
}
|
||||
await ElMessageBox.confirm(`确定要${actionTextMap[action]}活动“${row.productName}”吗?`, '状态变更确认', {
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
if (action === 'publish') await flashsaleApi.publish(row.id)
|
||||
if (action === 'pause') await flashsaleApi.pause(row.id)
|
||||
if (action === 'resume') await flashsaleApi.resume(row.id)
|
||||
if (action === 'end') await flashsaleApi.end(row.id)
|
||||
|
||||
ElMessage.success(`活动已${actionTextMap[action]}`)
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
const removeFlashSale = async (row: FlashSale) => {
|
||||
await ElMessageBox.confirm(`确定删除活动“${row.productName}”吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
})
|
||||
await flashsaleApi.delete(row.id)
|
||||
ElMessage.success('活动已删除')
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadFlashSales()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
query.keyword = ''
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadFlashSales()
|
||||
}
|
||||
|
||||
const reloadData = async () => {
|
||||
await Promise.all([loadStats(), loadProducts(), loadFlashSales()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-flashsales {
|
||||
.page-header {
|
||||
@apply flex justify-between items-center mb-6;
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
&.red { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
||||
|
||||
&__value { @apply text-3xl font-bold; }
|
||||
&__label { @apply text-sm opacity-90 mt-2; }
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 180px 100px 100px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-image,
|
||||
.detail-image {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
@apply font-medium text-slate-900;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
@apply text-xs text-slate-400 mt-1;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
@apply flex justify-end mt-4;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.price-line {
|
||||
@apply mt-4 mb-5 flex items-end gap-3;
|
||||
}
|
||||
|
||||
.flash-price {
|
||||
@apply text-3xl font-bold text-rose-500;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
@apply text-lg text-slate-400 line-through;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.detail-grid span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
514
flash-sale-frontend/src/pages/admin/monitor.vue
Normal file
514
flash-sale-frontend/src/pages/admin/monitor.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="admin-monitor page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">系统监控</h2>
|
||||
<p class="page-subtitle">复刻 JSP 监控页的指标卡、服务状态、性能趋势与实时日志</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" @change="toggleAutoRefresh" />
|
||||
<el-button @click="clearLogs">清空日志</el-button>
|
||||
<el-button type="primary" @click="refreshAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新全部
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue">
|
||||
<div class="mini-stat__value">{{ systemStatus.cpuUsage }}%</div>
|
||||
<div class="mini-stat__label">CPU 使用率</div>
|
||||
</div>
|
||||
<div class="mini-stat green">
|
||||
<div class="mini-stat__value">{{ systemStatus.memoryUsage }}%</div>
|
||||
<div class="mini-stat__label">内存使用率</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ userStats.onlineUsers }}</div>
|
||||
<div class="mini-stat__label">在线用户</div>
|
||||
</div>
|
||||
<div class="mini-stat purple">
|
||||
<div class="mini-stat__value">{{ systemStatus.requestCountToday || 0 }}</div>
|
||||
<div class="mini-stat__label">今日请求量</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">服务状态</h3>
|
||||
<p class="panel-subtitle">JSP 监控页中的服务检查模块</p>
|
||||
</div>
|
||||
<el-button text type="primary" @click="refreshAll">重新检查</el-button>
|
||||
</div>
|
||||
<div class="service-list">
|
||||
<div class="service-row">
|
||||
<div class="service-name"><span class="dot success"></span>应用服务</div>
|
||||
<el-tag type="success">运行中</el-tag>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<div class="service-name"><span :class="['dot', redisHealthy ? 'success' : 'danger']"></span>Redis 集群</div>
|
||||
<el-tag :type="redisHealthy ? 'success' : 'danger'">{{ redisHealthy ? '正常' : '异常' }}</el-tag>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<div class="service-name"><span :class="['dot', mysqlHealthy ? 'success' : 'danger']"></span>MySQL 服务</div>
|
||||
<el-tag :type="mysqlHealthy ? 'success' : 'danger'">{{ mysqlHealthy ? '正常' : '异常' }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">性能趋势</h3>
|
||||
<p class="panel-subtitle">CPU / 内存 / 磁盘曲线</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">Redis 节点</h3>
|
||||
<p class="panel-subtitle">来源于 `/api/admin/monitor/redis`</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="redisNodes" stripe>
|
||||
<el-table-column prop="node" label="节点" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memory" label="内存" width="110" />
|
||||
<el-table-column prop="connections" label="连接数" width="110" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">秒杀业务监控</h3>
|
||||
<p class="panel-subtitle">承接 JSP 中秒杀活动监控区域</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="business-metrics">
|
||||
<div class="business-item">
|
||||
<span>活动总数</span>
|
||||
<strong>{{ flashSaleStats.totalFlashSales }}</strong>
|
||||
</div>
|
||||
<div class="business-item">
|
||||
<span>进行中</span>
|
||||
<strong>{{ flashSaleStats.activeFlashSales }}</strong>
|
||||
</div>
|
||||
<div class="business-item">
|
||||
<span>即将开始</span>
|
||||
<strong>{{ flashSaleStats.upcomingFlashSales }}</strong>
|
||||
</div>
|
||||
<div class="business-item">
|
||||
<span>已结束</span>
|
||||
<strong>{{ flashSaleStats.endedFlashSales }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alerts-box">
|
||||
<el-alert
|
||||
:title="systemAlert.title"
|
||||
:description="systemAlert.description"
|
||||
:type="systemAlert.type"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3 class="panel-title">实时日志</h3>
|
||||
<p class="panel-subtitle">模拟 JSP 监控页中的滚动日志面板</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-list">
|
||||
<div v-for="item in logs" :key="item.id" class="log-row">
|
||||
<span class="log-time">{{ item.time }}</span>
|
||||
<el-tag size="small" :type="item.level === 'error' ? 'danger' : item.level === 'warn' ? 'warning' : 'success'">{{ item.level.toUpperCase() }}</el-tag>
|
||||
<span class="log-message">{{ item.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import dayjs from 'dayjs'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type { AdminFlashSaleStats, AdminUserStats, MonitorSystemStatus, RedisNodeStatus } from '@/types/admin'
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
time: string
|
||||
level: 'info' | 'warn' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const redisNodes = ref<RedisNodeStatus[]>([])
|
||||
const logs = ref<LogEntry[]>([])
|
||||
|
||||
const systemStatus = reactive<MonitorSystemStatus>({
|
||||
status: '未知',
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
diskUsage: 0,
|
||||
})
|
||||
|
||||
const flashSaleStats = reactive<AdminFlashSaleStats>({
|
||||
totalFlashSales: 0,
|
||||
activeFlashSales: 0,
|
||||
upcomingFlashSales: 0,
|
||||
endedFlashSales: 0,
|
||||
})
|
||||
|
||||
const userStats = reactive<AdminUserStats>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
newUsers: 0,
|
||||
onlineUsers: 0,
|
||||
})
|
||||
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
let timer: number | null = null
|
||||
const history = reactive({
|
||||
time: [] as string[],
|
||||
cpu: [] as number[],
|
||||
memory: [] as number[],
|
||||
disk: [] as number[],
|
||||
})
|
||||
|
||||
const redisHealthy = computed(() => systemStatus.redisStatus === '正常' || (redisNodes.value.length > 0 && redisNodes.value.every((item) => item.status === '正常')))
|
||||
const mysqlHealthy = computed(() => systemStatus.dbStatus === '正常')
|
||||
|
||||
const systemAlert = computed(() => {
|
||||
if (systemStatus.cpuUsage > 85 || systemStatus.memoryUsage > 85) {
|
||||
return {
|
||||
type: 'warning' as const,
|
||||
title: '资源占用偏高',
|
||||
description: '请重点关注 CPU 或内存使用率,必要时扩容或检查热点接口。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success' as const,
|
||||
title: '系统运行正常',
|
||||
description: '当前各关键服务健康,未发现明显异常。',
|
||||
}
|
||||
})
|
||||
|
||||
const appendLog = (level: LogEntry['level'], message: string) => {
|
||||
logs.value.unshift({
|
||||
id: Date.now() + Math.random(),
|
||||
time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
level,
|
||||
message,
|
||||
})
|
||||
logs.value = logs.value.slice(0, 16)
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartRef.value) return
|
||||
if (!chart) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
}
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 0 },
|
||||
grid: { left: 24, right: 24, top: 40, bottom: 24, containLabel: true },
|
||||
xAxis: { type: 'category', data: history.time },
|
||||
yAxis: { type: 'value', max: 100 },
|
||||
series: [
|
||||
{ name: 'CPU', type: 'line', smooth: true, data: history.cpu },
|
||||
{ name: '内存', type: 'line', smooth: true, data: history.memory },
|
||||
{ name: '磁盘', type: 'line', smooth: true, data: history.disk },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const pushHistory = () => {
|
||||
history.time.push(dayjs().format('HH:mm:ss'))
|
||||
history.cpu.push(systemStatus.cpuUsage)
|
||||
history.memory.push(systemStatus.memoryUsage)
|
||||
history.disk.push(systemStatus.diskUsage)
|
||||
|
||||
if (history.time.length > 12) {
|
||||
history.time.shift()
|
||||
history.cpu.shift()
|
||||
history.memory.shift()
|
||||
history.disk.shift()
|
||||
}
|
||||
}
|
||||
|
||||
const loadMetrics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [systemRes, redisRes, flashSaleRes, userRes] = await Promise.all([
|
||||
adminApi.getSystemStatus(),
|
||||
adminApi.getRedisStatus(),
|
||||
adminApi.getFlashSaleStats(),
|
||||
adminApi.getUserStats(),
|
||||
])
|
||||
|
||||
Object.assign(systemStatus, systemRes.data)
|
||||
Object.assign(flashSaleStats, flashSaleRes.data)
|
||||
Object.assign(userStats, userRes.data)
|
||||
redisNodes.value = redisRes.data
|
||||
|
||||
pushHistory()
|
||||
await nextTick()
|
||||
renderChart()
|
||||
|
||||
appendLog('info', `系统状态刷新完成,CPU ${systemStatus.cpuUsage}% / 内存 ${systemStatus.memoryUsage}% / 今日请求 ${systemStatus.requestCountToday || 0}`)
|
||||
if (!redisHealthy.value) {
|
||||
appendLog('warn', '检测到 Redis 节点存在异常,请尽快排查')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = async () => {
|
||||
await loadMetrics()
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
appendLog('info', '日志面板已清空')
|
||||
}
|
||||
|
||||
const setupTimer = () => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
if (autoRefresh.value) {
|
||||
timer = window.setInterval(() => {
|
||||
loadMetrics()
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
setupTimer()
|
||||
appendLog('info', autoRefresh.value ? '已开启自动刷新' : '已切换为手动刷新')
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
appendLog('info', '监控模块已启动')
|
||||
loadMetrics()
|
||||
setupTimer()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
|
||||
&__value { @apply text-3xl font-bold; }
|
||||
&__label { @apply text-sm opacity-90 mt-2; }
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex items-center justify-between mb-4 gap-4;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-lg font-semibold text-slate-900;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.service-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.success { background: #10b981; }
|
||||
.dot.danger { background: #ef4444; }
|
||||
|
||||
.chart-container {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.business-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.business-item {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.business-item strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.alerts-box {
|
||||
@apply mt-5;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 90px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid,
|
||||
.content-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid,
|
||||
.content-grid,
|
||||
.business-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,416 @@
|
||||
<template>
|
||||
<div class="admin-orders">
|
||||
<div class="admin-orders page-shell">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<div>
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<p class="page-subtitle">延续 JSP 后台的统计卡片、筛选表格与订单详情操作</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="reloadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="migrateItems">迁移旧订单明细</el-button>
|
||||
<el-button @click="exportCsv">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<el-table :data="[]" stripe>
|
||||
<el-table-column prop="orderNo" label="订单号" />
|
||||
<el-table-column prop="username" label="用户" />
|
||||
<el-table-column prop="totalAmount" label="金额" />
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column prop="createdAt" label="创建时间" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default>
|
||||
<el-button text type="primary" size="small">详情</el-button>
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue">
|
||||
<div class="mini-stat__value">{{ stats.totalOrders }}</div>
|
||||
<div class="mini-stat__label">订单总数</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ stats.pendingOrders }}</div>
|
||||
<div class="mini-stat__label">待处理</div>
|
||||
</div>
|
||||
<div class="mini-stat green">
|
||||
<div class="mini-stat__value">{{ stats.paidOrders }}</div>
|
||||
<div class="mini-stat__label">已支付</div>
|
||||
</div>
|
||||
<div class="mini-stat purple">
|
||||
<div class="mini-stat__value">¥{{ formatCurrency(stats.totalAmount) }}</div>
|
||||
<div class="mini-stat__label">成交总额</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input v-model="query.keyword" clearable placeholder="搜索订单号 / 用户 / 商品" @keyup.enter="loadOrders">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||
<el-option label="待支付" value="1" />
|
||||
<el-option label="已支付" value="2" />
|
||||
<el-option label="已发货" value="3" />
|
||||
<el-option label="已完成" value="4" />
|
||||
<el-option label="已取消" value="5" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="displayOrders" stripe>
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="120" />
|
||||
<el-table-column prop="username" label="用户" min-width="100" />
|
||||
<el-table-column prop="productName" label="商品" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column prop="totalAmount" label="金额" width="110">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.totalAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFlashSale ? 'danger' : 'info'">{{ row.isFlashSale ? '秒杀订单' : '普通订单' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button v-if="row.status === 'PAID'" text type="success" @click="shipOrder(row)">发货</el-button>
|
||||
<el-button v-if="row.status === 'PENDING' || row.status === 'PAID'" text type="danger" @click="cancelOrder(row)">取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadOrders"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="订单详情" width="760px">
|
||||
<div v-if="currentOrder" class="detail-shell">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h3>{{ currentOrder.orderNo }}</h3>
|
||||
<p>{{ currentOrder.username }} · {{ formatTime(currentOrder.createdAt) }}</p>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(currentOrder.status)">{{ getStatusText(currentOrder.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="item-card" v-for="item in currentOrder.items" :key="item.id">
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="item-image" img-class="item-image" />
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.productName }}</div>
|
||||
<div class="item-meta">¥{{ formatCurrency(item.price) }} × {{ item.quantity }}</div>
|
||||
</div>
|
||||
<div class="item-total">¥{{ formatCurrency(item.subtotal) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div><span>订单编号:</span>{{ currentOrder.orderNo }}</div>
|
||||
<div><span>支付方式:</span>{{ currentOrder.paymentMethod || '未支付' }}</div>
|
||||
<div><span>创建时间:</span>{{ formatTime(currentOrder.createdAt) }}</div>
|
||||
<div><span>更新时间:</span>{{ formatTime(currentOrder.updatedAt) }}</div>
|
||||
<div><span>实付金额:</span>¥{{ formatCurrency(currentOrder.paymentAmount) }}</div>
|
||||
<div><span>订单状态:</span>{{ getStatusText(currentOrder.status) }}</div>
|
||||
<div v-if="currentOrder.paidAt"><span>支付时间:</span>{{ formatTime(currentOrder.paidAt) }}</div>
|
||||
<div v-if="currentOrder.shippedAt"><span>发货时间:</span>{{ formatTime(currentOrder.shippedAt) }}</div>
|
||||
<div v-if="currentOrder.completedAt"><span>完成时间:</span>{{ formatTime(currentOrder.completedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import { orderApi } from '@/api/modules/order'
|
||||
import type { Order } from '@/types/api'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import type { AdminOrderRow, AdminOrderStats } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const orders = ref<AdminOrderRow[]>([])
|
||||
const currentOrder = ref<Order | null>(null)
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const stats = reactive<AdminOrderStats>({
|
||||
totalOrders: 0,
|
||||
paidOrders: 0,
|
||||
pendingOrders: 0,
|
||||
completedOrders: 0,
|
||||
cancelledOrders: 0,
|
||||
totalAmount: 0,
|
||||
})
|
||||
|
||||
const displayOrders = computed(() => {
|
||||
if (!query.keyword) return orders.value
|
||||
const keyword = query.keyword.toLowerCase()
|
||||
return orders.value.filter((item) => [item.orderNo, item.username, item.productName].some((field) => field.toLowerCase().includes(keyword)))
|
||||
})
|
||||
|
||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
SHIPPED: '已发货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'primary',
|
||||
SHIPPED: 'success',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getOrderStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
|
||||
const loadOrders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getOrders({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
status: query.status || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
})
|
||||
orders.value = res.data.orders
|
||||
pagination.total = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
const res = await orderApi.getDetail(id)
|
||||
currentOrder.value = res.data
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const shipOrder = async (row: AdminOrderRow) => {
|
||||
await ElMessageBox.confirm(`确定将订单 ${row.orderNo} 标记为已发货吗?`, '发货确认', { type: 'warning' })
|
||||
await orderApi.ship(row.id)
|
||||
ElMessage.success('订单已发货')
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
const cancelOrder = async (row: AdminOrderRow) => {
|
||||
await ElMessageBox.confirm(`确定取消订单 ${row.orderNo} 吗?`, '取消确认', { type: 'warning' })
|
||||
await orderApi.updateStatus(row.id, 5, '管理后台取消订单')
|
||||
ElMessage.success('订单已取消')
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
query.keyword = ''
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const rows = displayOrders.value.map((item) => [item.orderNo, item.username, item.productName, item.quantity, item.totalAmount, getStatusText(item.status), formatTime(item.createdAt)])
|
||||
const header = ['订单号', '用户', '商品', '数量', '金额', '状态', '创建时间']
|
||||
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
||||
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `orders-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
const migrateItems = async () => {
|
||||
const res = await adminApi.migrateLegacyOrderItems()
|
||||
ElMessage.success('迁移完成:迁移 ' + res.data.migrated + ' 条,跳过 ' + res.data.skipped + ' 条')
|
||||
reloadData()
|
||||
}
|
||||
|
||||
const reloadData = async () => {
|
||||
await Promise.all([loadStats(), loadOrders()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-orders {
|
||||
.page-header {
|
||||
@apply flex justify-between items-center mb-6;
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
|
||||
&__value { @apply text-3xl font-bold; }
|
||||
&__label { @apply text-sm opacity-90 mt-2; }
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 180px 100px 100px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
@apply flex justify-end mt-4;
|
||||
}
|
||||
|
||||
.detail-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
@apply text-xl font-semibold text-slate-900;
|
||||
}
|
||||
|
||||
.detail-header p {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
@apply font-medium text-slate-900;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.item-total {
|
||||
@apply text-lg font-semibold text-rose-500;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-grid span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +1,500 @@
|
||||
<template>
|
||||
<div class="admin-products">
|
||||
<div class="admin-products page-shell">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">商品管理</h2>
|
||||
<el-button type="primary">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
添加商品
|
||||
</el-button>
|
||||
<div>
|
||||
<h2 class="page-title">商品管理</h2>
|
||||
<p class="page-subtitle">复刻 JSP 后台的筛选、列表、查看与增改删流程</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="reloadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加商品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<el-table :data="[]" stripe>
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue">
|
||||
<div class="mini-stat__value">{{ stats.totalProducts }}</div>
|
||||
<div class="mini-stat__label">商品总数</div>
|
||||
</div>
|
||||
<div class="mini-stat green">
|
||||
<div class="mini-stat__value">{{ stats.activeProducts }}</div>
|
||||
<div class="mini-stat__label">上架商品</div>
|
||||
</div>
|
||||
<div class="mini-stat gray">
|
||||
<div class="mini-stat__value">{{ stats.inactiveProducts }}</div>
|
||||
<div class="mini-stat__label">下架商品</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ stats.lowStockProducts }}</div>
|
||||
<div class="mini-stat__label">低库存商品</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索商品名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="query.category" clearable placeholder="全部分类" @change="handleSearch">
|
||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||
<el-option label="上架" :value="1" />
|
||||
<el-option label="下架" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="products" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="商品名称" />
|
||||
<el-table-column prop="price" label="价格" />
|
||||
<el-table-column prop="stock" label="库存" />
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default>
|
||||
<el-button text type="primary" size="small">编辑</el-button>
|
||||
<el-button text type="danger" size="small">删除</el-button>
|
||||
<el-table-column label="商品图片" width="100">
|
||||
<template #default="{ row }">
|
||||
<SafeImage :src="row.imageUrl" :alt="row.name" wrapper-class="product-image" img-class="product-image" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="description" label="商品描述" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="price" label="价格" width="110">
|
||||
<template #default="{ row }">¥{{ formatCurrency(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.stock > 10 ? 'success' : 'warning'">{{ row.stock }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '上架' : '下架' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDetail(row.id)">查看</el-button>
|
||||
<el-button text type="primary" @click="openEditDialog(row.id)">编辑</el-button>
|
||||
<el-button text type="danger" @click="removeProduct(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadProducts"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '添加商品' : '编辑商品'" width="720px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="商品名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入商品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品分类" prop="category">
|
||||
<el-select v-model="form.category" placeholder="请选择分类" class="w-full" allow-create filterable default-first-option>
|
||||
<el-option v-for="item in categories" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="价格" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0.01" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="库存" prop="stock">
|
||||
<el-input-number v-model="form.stock" :min="0" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">上架</el-radio>
|
||||
<el-radio :label="0">下架</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品图片" prop="imageUrl">
|
||||
<ImageUpload v-model="form.imageUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="商品详情" width="760px">
|
||||
<div v-if="detail" class="detail-layout">
|
||||
<SafeImage :src="detail.imageUrl" :alt="detail.name" wrapper-class="detail-image" img-class="detail-image" />
|
||||
<div class="detail-content">
|
||||
<h3>{{ detail.name }}</h3>
|
||||
<div class="detail-price">¥{{ formatCurrency(detail.price) }}</div>
|
||||
<div class="detail-grid">
|
||||
<div><span>分类:</span>{{ detail.category }}</div>
|
||||
<div><span>库存:</span>{{ detail.stock }}</div>
|
||||
<div><span>状态:</span>{{ detail.status === 1 ? '上架' : '下架' }}</div>
|
||||
<div><span>创建时间:</span>{{ formatTime(detail.createdAt) }}</div>
|
||||
<div><span>更新时间:</span>{{ formatTime(detail.updatedAt || detail.createdAt) }}</div>
|
||||
<div><span>总销量:</span>{{ detail.totalSales || 0 }}</div>
|
||||
<div><span>浏览次数:</span>{{ detail.viewCount || 0 }}</div>
|
||||
<div><span>总收入:</span>¥{{ formatCurrency(detail.totalRevenue || 0) }}</div>
|
||||
<div><span>评分:</span>{{ Number(detail.rating || 0).toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="detail-description">{{ detail.description || '暂无描述' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button v-if="detail" type="primary" @click="openEditDialog(detail.id)">编辑商品</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import type { AdminProductRow, AdminProductStats } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const detail = ref<AdminProductRow | null>(null)
|
||||
const products = ref<AdminProductRow[]>([])
|
||||
const categories = ref<string[]>([])
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
category: '',
|
||||
status: '' as number | '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const stats = reactive<AdminProductStats>({
|
||||
totalProducts: 0,
|
||||
activeProducts: 0,
|
||||
inactiveProducts: 0,
|
||||
lowStockProducts: 0,
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
price: 0.01,
|
||||
stock: 0,
|
||||
status: 1,
|
||||
imageUrl: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请选择商品分类', trigger: 'change' }],
|
||||
price: [{ required: true, message: '请输入商品价格', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
const formatCurrency = (value: number) => Number(value || 0).toFixed(2)
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.category = ''
|
||||
form.price = 0.01
|
||||
form.stock = 0
|
||||
form.status = 1
|
||||
form.imageUrl = ''
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getProductStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
const res = await productApi.getCategories()
|
||||
categories.value = res.success ? res.data : []
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getProducts({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status === '' ? undefined : query.status,
|
||||
category: query.category || undefined,
|
||||
})
|
||||
products.value = res.data.products
|
||||
pagination.total = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadProductDetail = async (id: number) => {
|
||||
const res = await adminApi.getProduct(id)
|
||||
return res.data
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetForm()
|
||||
formMode.value = 'create'
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = async (id: number) => {
|
||||
const product = await loadProductDetail(id)
|
||||
detail.value = product
|
||||
formMode.value = 'edit'
|
||||
form.id = product.id
|
||||
form.name = product.name
|
||||
form.description = product.description
|
||||
form.category = product.category
|
||||
form.price = product.price
|
||||
form.stock = product.stock
|
||||
form.status = product.status
|
||||
form.imageUrl = product.imageUrl
|
||||
formVisible.value = true
|
||||
detailVisible.value = false
|
||||
}
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
detail.value = await loadProductDetail(id)
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
category: form.category,
|
||||
price: form.price,
|
||||
stock: form.stock,
|
||||
status: form.status,
|
||||
imageUrl: form.imageUrl,
|
||||
}
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
await adminApi.createProduct(payload)
|
||||
ElMessage.success('商品添加成功')
|
||||
} else {
|
||||
await adminApi.updateProduct(form.id, payload)
|
||||
ElMessage.success('商品更新成功')
|
||||
}
|
||||
|
||||
formVisible.value = false
|
||||
await reloadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeProduct = async (row: AdminProductRow) => {
|
||||
await ElMessageBox.confirm(`确定删除商品“${row.name}”吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
})
|
||||
await adminApi.deleteProduct(row.id)
|
||||
ElMessage.success('商品已删除')
|
||||
await reloadData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadProducts()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
query.keyword = ''
|
||||
query.category = ''
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadProducts()
|
||||
}
|
||||
|
||||
const reloadData = async () => {
|
||||
await Promise.all([loadStats(), loadCategories(), loadProducts()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-products {
|
||||
.page-header {
|
||||
@apply flex justify-between items-center mb-6;
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.gray { background: linear-gradient(135deg, #64748b, #475569); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
|
||||
&__value {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
&__label {
|
||||
@apply text-sm opacity-90 mt-2;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 180px 180px 100px 100px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
@apply flex justify-end mt-4;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-content h3 {
|
||||
@apply text-2xl font-semibold text-slate-900;
|
||||
}
|
||||
|
||||
.detail-price {
|
||||
@apply text-3xl font-bold text-rose-500 mt-3 mb-4;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.detail-grid span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
@apply mt-5 text-sm leading-6 text-slate-600 bg-slate-50 rounded-xl p-4;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
flash-sale-frontend/src/pages/admin/reviews.vue
Normal file
123
flash-sale-frontend/src/pages/admin/reviews.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">评价管理</h2>
|
||||
<p class="page-subtitle">查看、隐藏和回复用户评价</p>
|
||||
</div>
|
||||
<el-button @click="reloadData"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue"><div class="mini-stat__value">{{ stats.totalReviews }}</div><div class="mini-stat__label">评价总数</div></div>
|
||||
<div class="mini-stat green"><div class="mini-stat__value">{{ stats.todayReviews }}</div><div class="mini-stat__label">今日新增</div></div>
|
||||
<div class="mini-stat orange"><div class="mini-stat__value">{{ stats.averageRating.toFixed(1) }}</div><div class="mini-stat__label">平均评分</div></div>
|
||||
<div class="mini-stat purple"><div class="mini-stat__value">{{ stats.fiveStarReviews }}</div><div class="mini-stat__label">五星评价</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input v-model="keyword" clearable placeholder="搜索用户 / 商品 / 评价内容" @keyup.enter="loadReviews" />
|
||||
<el-button type="primary" @click="loadReviews">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="reviews" stripe>
|
||||
<el-table-column prop="productName" label="商品" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="rating" label="评分" width="120"><template #default="{ row }"><el-rate :model-value="row.rating" disabled /></template></el-table-column>
|
||||
<el-table-column prop="content" label="评价内容" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="statusText" label="状态" width="90" />
|
||||
<el-table-column prop="adminReply" label="回复" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="时间" min-width="170" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openReply(row)">回复</el-button>
|
||||
<el-button text :type="row.status === 1 ? 'warning' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '隐藏' : '显示' }}</el-button>
|
||||
<el-button text type="danger" @click="removeReview(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="loadReviews" @size-change="loadReviews" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="replyVisible" title="评价回复" width="600px">
|
||||
<el-input v-model="replyText" type="textarea" :rows="5" maxlength="500" show-word-limit placeholder="请输入管理员回复" />
|
||||
<template #footer>
|
||||
<el-button @click="replyVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitReply">保存回复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type { AdminReviewRow, AdminReviewStats } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const size = ref(10)
|
||||
const total = ref(0)
|
||||
const reviews = ref<AdminReviewRow[]>([])
|
||||
const stats = reactive<AdminReviewStats>({ totalReviews: 0, todayReviews: 0, averageRating: 0, fiveStarReviews: 0 })
|
||||
const replyVisible = ref(false)
|
||||
const currentReviewId = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getReviewStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
const loadReviews = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getReviews({ page: page.value, size: size.value, keyword: keyword.value || undefined })
|
||||
reviews.value = res.data.reviews
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
const openReply = (row: AdminReviewRow) => { currentReviewId.value = row.id; replyText.value = row.adminReply || ''; replyVisible.value = true }
|
||||
const submitReply = async () => {
|
||||
if (!currentReviewId.value) return
|
||||
await adminApi.updateReview(currentReviewId.value, { adminReply: replyText.value, status: 1 })
|
||||
ElMessage.success('回复已保存')
|
||||
replyVisible.value = false
|
||||
loadReviews()
|
||||
}
|
||||
const toggleStatus = async (row: AdminReviewRow) => {
|
||||
await adminApi.updateReview(row.id, { status: row.status === 1 ? 0 : 1 })
|
||||
ElMessage.success('状态已更新')
|
||||
loadStats(); loadReviews()
|
||||
}
|
||||
const removeReview = async (id: number) => {
|
||||
await ElMessageBox.confirm('确定删除该评价吗?', '提示', { type: 'warning' })
|
||||
await adminApi.deleteReview(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadStats(); loadReviews()
|
||||
}
|
||||
const reloadData = async () => { await Promise.all([loadStats(), loadReviews()]) }
|
||||
onMounted(() => { reloadData() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { display:flex; flex-direction:column; gap:20px; }
|
||||
.page-header { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
|
||||
.page-title { @apply text-2xl font-bold text-slate-900; }
|
||||
.page-subtitle { @apply text-sm text-slate-500 mt-1; }
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }
|
||||
.mini-stat { @apply rounded-xl text-white p-5 shadow-sm; }
|
||||
.mini-stat.blue { background:linear-gradient(135deg,#3b82f6,#2563eb); }
|
||||
.mini-stat.green { background:linear-gradient(135deg,#10b981,#059669); }
|
||||
.mini-stat.orange { background:linear-gradient(135deg,#f59e0b,#ea580c); }
|
||||
.mini-stat.purple { background:linear-gradient(135deg,#8b5cf6,#7c3aed); }
|
||||
.mini-stat__value { @apply text-3xl font-bold; }
|
||||
.mini-stat__label { @apply text-sm opacity-90 mt-2; }
|
||||
.panel-card { @apply bg-white rounded-xl shadow-sm p-5; }
|
||||
.filter-card { display:grid; grid-template-columns:1fr 100px; gap:12px; }
|
||||
.table-footer { @apply flex justify-end mt-4; }
|
||||
</style>
|
||||
@@ -1,42 +1,286 @@
|
||||
<template>
|
||||
<div class="admin-users">
|
||||
<div class="admin-users page-shell">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<div>
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<p class="page-subtitle">对应 JSP 的用户统计、筛选检索和分页列表</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="reloadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button @click="exportCsv">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<el-table :data="[]" stripe>
|
||||
<div class="stats-grid">
|
||||
<div class="mini-stat blue">
|
||||
<div class="mini-stat__value">{{ stats.totalUsers }}</div>
|
||||
<div class="mini-stat__label">总用户数</div>
|
||||
</div>
|
||||
<div class="mini-stat green">
|
||||
<div class="mini-stat__value">{{ stats.activeUsers }}</div>
|
||||
<div class="mini-stat__label">活跃用户</div>
|
||||
</div>
|
||||
<div class="mini-stat orange">
|
||||
<div class="mini-stat__value">{{ stats.newUsers }}</div>
|
||||
<div class="mini-stat__label">今日新增</div>
|
||||
</div>
|
||||
<div class="mini-stat purple">
|
||||
<div class="mini-stat__value">{{ stats.onlineUsers }}</div>
|
||||
<div class="mini-stat__label">在线用户</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card filter-card">
|
||||
<el-input v-model="query.keyword" clearable placeholder="搜索用户名 / 邮箱 / 手机号" @keyup.enter="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态" @change="handleSearch">
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<el-table v-loading="loading" :data="users" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column prop="role" label="角色" />
|
||||
<el-table-column prop="status" label="状态" />
|
||||
<el-table-column prop="createdAt" label="注册时间" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default>
|
||||
<el-button text type="primary" size="small">编辑</el-button>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">{{ row.role === 'ADMIN' ? '管理员' : '普通用户' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.statusText }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isOnline ? 'success' : 'info'">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLogin" label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ row.lastLogin ? formatTime(row.lastLogin) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="viewUser(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadUsers"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="用户详情" width="620px">
|
||||
<div v-if="currentUser" class="detail-grid">
|
||||
<div><span>用户ID:</span>{{ currentUser.id }}</div>
|
||||
<div><span>用户名:</span>{{ currentUser.username }}</div>
|
||||
<div><span>邮箱:</span>{{ currentUser.email || '-' }}</div>
|
||||
<div><span>手机号:</span>{{ currentUser.phone || '-' }}</div>
|
||||
<div><span>角色:</span>{{ currentUser.role === 'ADMIN' ? '管理员' : '普通用户' }}</div>
|
||||
<div><span>状态:</span>{{ currentUser.statusText }}</div>
|
||||
<div><span>在线状态:</span>{{ currentUser.isOnline ? '在线' : '离线' }}</div>
|
||||
<div><span>注册时间:</span>{{ formatTime(currentUser.createdAt) }}</div>
|
||||
<div v-if="currentUser.lastLogin"><span>最后登录:</span>{{ formatTime(currentUser.lastLogin) }}</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { adminApi } from '@/api/modules/admin'
|
||||
import type { AdminUserRow, AdminUserStats } from '@/types/admin'
|
||||
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const users = ref<AdminUserRow[]>([])
|
||||
const currentUser = ref<AdminUserRow | null>(null)
|
||||
|
||||
const query = reactive({
|
||||
keyword: '',
|
||||
status: '' as number | '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const stats = reactive<AdminUserStats>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
newUsers: 0,
|
||||
onlineUsers: 0,
|
||||
})
|
||||
|
||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await adminApi.getUserStats()
|
||||
Object.assign(stats, res.data)
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getUsers({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status === '' ? undefined : query.status,
|
||||
})
|
||||
users.value = res.data.users
|
||||
pagination.total = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewUser = (row: AdminUserRow) => {
|
||||
currentUser.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
query.keyword = ''
|
||||
query.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const rows = users.value.map((item) => [item.id, item.username, item.email, item.phone, item.role, item.statusText, item.isOnline ? '在线' : '离线', formatTime(item.createdAt), item.lastLogin ? formatTime(item.lastLogin) : ''])
|
||||
const header = ['ID', '用户名', '邮箱', '手机号', '角色', '状态', '在线', '注册时间', '最后登录']
|
||||
const csv = [header, ...rows].map((row) => row.join(',')).join('\n')
|
||||
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `users-${dayjs().format('YYYYMMDD-HHmmss')}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
const reloadData = async () => {
|
||||
await Promise.all([loadStats(), loadUsers()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-users {
|
||||
.page-header {
|
||||
@apply flex justify-between items-center mb-6;
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
@apply rounded-xl text-white p-5 shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
|
||||
&__value { @apply text-3xl font-bold; }
|
||||
&__label { @apply text-sm opacity-90 mt-2; }
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
@apply bg-white rounded-xl shadow-sm p-5;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 180px 100px 100px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
@apply flex justify-end mt-4;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-grid span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -62,12 +62,7 @@
|
||||
/>
|
||||
|
||||
<!-- 商品图片 -->
|
||||
<img
|
||||
:src="item.productImage || '/default-product.png'"
|
||||
:alt="item.productName"
|
||||
class="w-24 h-24 object-cover rounded"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100" img-class="w-24 h-24 object-cover rounded-lg" />
|
||||
|
||||
<!-- 商品信息 -->
|
||||
<div class="flex-1">
|
||||
@@ -192,11 +187,7 @@
|
||||
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
|
||||
@click="router.push(`/product/${item.id}`)"
|
||||
>
|
||||
<img
|
||||
:src="item.imageUrl"
|
||||
:alt="item.name"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
>
|
||||
<SafeImage :src="item.imageUrl" :alt="item.name" wrapper-class="w-16 h-16 rounded overflow-hidden bg-gray-100" img-class="w-16 h-16 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm line-clamp-2">{{ item.name }}</p>
|
||||
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
|
||||
@@ -217,7 +208,9 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import { cartApi } from '@/api/modules/cart'
|
||||
import type { Product } from '@/types/api'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
@@ -239,10 +232,7 @@ const indeterminate = computed(() => {
|
||||
})
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = () => {
|
||||
@@ -305,8 +295,17 @@ const handleCheckout = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 跳转到订单确认页
|
||||
ElMessage.info('功能开发中...')
|
||||
try {
|
||||
const selectedIds = cartStore.selectedItems.map((item) => item.id)
|
||||
const res = await cartApi.checkout(selectedIds)
|
||||
if (res.success) {
|
||||
ElMessage.success('下单成功,请及时支付')
|
||||
await cartStore.fetchCart()
|
||||
router.push(`/order/${res.data.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('购物车结算失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载推荐商品
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flashsale-detail-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item :to="{ path: '/flashsale' }">秒杀活动</el-breadcrumb-item>
|
||||
@@ -15,24 +14,19 @@
|
||||
|
||||
<div v-else-if="!flashSale" class="text-center py-12">
|
||||
<el-empty description="秒杀活动不存在" />
|
||||
<el-button type="primary" @click="router.push('/flashsale')">
|
||||
返回秒杀列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="router.push('/flashsale')">返回秒杀列表</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
|
||||
<!-- 左侧:商品图片 -->
|
||||
<div>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="flashSale.productImageUrl || '/default-product.png'"
|
||||
<SafeImage
|
||||
:src="flashSale.productImageUrl"
|
||||
:alt="flashSale.productName"
|
||||
class="w-full rounded-lg"
|
||||
@error="handleImageError"
|
||||
>
|
||||
|
||||
<!-- 状态标签 -->
|
||||
wrapper-class="w-full rounded-lg overflow-hidden bg-gray-100"
|
||||
img-class="w-full rounded-lg object-cover"
|
||||
/>
|
||||
<div class="absolute top-4 left-4">
|
||||
<el-tag :type="statusType" size="large" effect="dark">
|
||||
<el-icon class="mr-1"><Lightning /></el-icon>
|
||||
@@ -42,55 +36,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:商品信息 -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-4">{{ flashSale.productName }}</h1>
|
||||
|
||||
<!-- 价格信息 -->
|
||||
<div class="bg-red-50 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-end mb-2">
|
||||
<span class="text-sm text-gray-500 mr-2">秒杀价</span>
|
||||
<span class="text-4xl font-bold text-red-500">¥{{ flashSale.flashPrice }}</span>
|
||||
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
|
||||
<span class="ml-2 px-2 py-1 bg-red-500 text-white text-sm rounded">
|
||||
{{ discountPercent }}% OFF
|
||||
</span>
|
||||
<span class="ml-2 px-2 py-1 bg-red-500 text-white text-sm rounded">{{ discountPercent }}% OFF</span>
|
||||
</div>
|
||||
|
||||
<!-- 活动时间 -->
|
||||
<div class="text-sm text-gray-600 mt-4">
|
||||
<p>开始时间:{{ formatTime(flashSale.startTime) }}</p>
|
||||
<p>结束时间:{{ formatTime(flashSale.endTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 库存信息 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-gray-600">库存情况</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }} 件
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }} 件</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="stockPercent"
|
||||
:stroke-width="10"
|
||||
:color="progressColor"
|
||||
/>
|
||||
<el-progress :percentage="stockPercent" :stroke-width="10" :color="progressColor" />
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
|
||||
<CountDown
|
||||
:end-time="endTime"
|
||||
@finish="handleFinish"
|
||||
/>
|
||||
<CountDown :end-time="endTime" @finish="handleFinish" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限购信息 -->
|
||||
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="flex items-center text-blue-700">
|
||||
<el-icon class="mr-2"><InfoFilled /></el-icon>
|
||||
@@ -98,47 +74,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="space-y-4">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="large"
|
||||
class="w-full"
|
||||
:disabled="!canParticipate"
|
||||
:loading="participating"
|
||||
@click="handleParticipate"
|
||||
>
|
||||
<el-button type="danger" size="large" class="w-full" :disabled="!canParticipate" :loading="participating" @click="handleParticipate">
|
||||
<el-icon class="mr-2"><Lightning /></el-icon>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<el-button size="large" class="flex-1" @click="handleViewProduct">
|
||||
查看商品详情
|
||||
</el-button>
|
||||
<el-button size="large" class="flex-1" @click="handleShare">
|
||||
<el-icon class="mr-1"><Share /></el-icon>
|
||||
分享
|
||||
</el-button>
|
||||
<el-button size="large" class="flex-1" @click="handleViewProduct">查看商品详情</el-button>
|
||||
<el-button size="large" class="flex-1" @click="handleShare"><el-icon class="mr-1"><Share /></el-icon>分享活动</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动说明 -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 class="font-semibold mb-2">活动说明</h3>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p>• 秒杀商品数量有限,先到先得</p>
|
||||
<p>• 每个用户限购{{ flashSale.limitPerUser }}件</p>
|
||||
<p>• 秒杀成功后请在30分钟内完成支付</p>
|
||||
<p>• 商品售出后不支持退换</p>
|
||||
</div>
|
||||
<div class="mt-8 p-4 bg-yellow-50 rounded-lg">
|
||||
<h3 class="font-semibold mb-2">抢购说明</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 秒杀商品数量有限,先到先得</li>
|
||||
<li>• 每个用户限购{{ flashSale.limitPerUser }}件</li>
|
||||
<li>• 下单后请在30分钟内完成支付</li>
|
||||
<li>• 商品一经售出,非质量问题不支持退换</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 商品描述 -->
|
||||
<div v-if="flashSale.description" class="border-t px-8 py-6">
|
||||
<h3 class="text-xl font-semibold mb-4">活动详情</h3>
|
||||
<h3 class="text-xl font-semibold mb-4">活动说明</h3>
|
||||
<div class="text-gray-600" v-html="flashSale.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,6 +111,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CountDown from '@/components/business/CountDown.vue'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { FlashSale } from '@/types/api'
|
||||
@@ -164,7 +125,8 @@ const loading = ref(false)
|
||||
const participating = ref(false)
|
||||
const flashSale = ref<FlashSale | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
const statusType = computed(() => {
|
||||
if (!flashSale.value) return 'info'
|
||||
switch (flashSale.value.status) {
|
||||
@@ -179,9 +141,9 @@ const statusText = computed(() => {
|
||||
if (!flashSale.value) return ''
|
||||
switch (flashSale.value.status) {
|
||||
case 'UPCOMING': return '即将开始'
|
||||
case 'ACTIVE': return '秒杀进行中'
|
||||
case 'ACTIVE': return '秒杀中'
|
||||
case 'ENDED': return '已结束'
|
||||
default: return '未知'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -192,7 +154,7 @@ const discountPercent = computed(() => {
|
||||
|
||||
const stockPercent = computed(() => {
|
||||
if (!flashSale.value || flashSale.value.flashStock === 0) return 0
|
||||
return Math.round(flashSale.value.remainingStock / flashSale.value.flashStock * 100)
|
||||
return Math.round((flashSale.value.remainingStock / flashSale.value.flashStock) * 100)
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
@@ -219,27 +181,11 @@ const buttonText = computed(() => {
|
||||
return '立即抢购'
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
// 加载秒杀详情
|
||||
const loadFlashSaleDetail = async () => {
|
||||
const loadDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = Number(route.params.id)
|
||||
const res = await flashsaleApi.getDetail(id)
|
||||
|
||||
if (res.success) {
|
||||
flashSale.value = res.data
|
||||
}
|
||||
const res = await flashsaleApi.getDetail(Number(route.params.id))
|
||||
if (res.success) flashSale.value = res.data
|
||||
} catch (error) {
|
||||
console.error('加载秒杀详情失败:', error)
|
||||
ElMessage.error('加载失败')
|
||||
@@ -248,81 +194,49 @@ const loadFlashSaleDetail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 参与秒杀
|
||||
const handleParticipate = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: route.fullPath }
|
||||
})
|
||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
if (!flashSale.value || !canParticipate.value) return
|
||||
|
||||
await ElMessageBox.confirm(`确定要抢购该商品吗?\n秒杀价:¥${flashSale.value.flashPrice}`, '抢购确认', {
|
||||
confirmButtonText: '立即抢购',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
participating.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要抢购该商品吗?\n秒杀价:¥${flashSale.value.flashPrice}`,
|
||||
'确认抢购',
|
||||
{
|
||||
confirmButtonText: '确定抢购',
|
||||
cancelButtonText: '再想想',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
participating.value = true
|
||||
const startTime = Date.now()
|
||||
|
||||
const res = await flashsaleApi.participate({
|
||||
flashSaleId: flashSale.value.id,
|
||||
quantity: 1,
|
||||
timestamp: startTime
|
||||
})
|
||||
|
||||
const res = await flashsaleApi.participate({ flashSaleId: flashSale.value.id, quantity: 1 })
|
||||
if (res.success) {
|
||||
const duration = Date.now() - startTime
|
||||
ElMessage.success(`抢购成功!耗时${duration}ms`)
|
||||
|
||||
// 跳转到订单页面
|
||||
setTimeout(() => {
|
||||
router.push('/orders')
|
||||
}, 1500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '抢购失败')
|
||||
ElMessage.success('抢购成功')
|
||||
router.push(`/order/${res.data.orderId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('参与秒杀失败:', error)
|
||||
} finally {
|
||||
participating.value = false
|
||||
// 重新加载详情
|
||||
loadFlashSaleDetail()
|
||||
}
|
||||
}
|
||||
|
||||
// 查看商品详情
|
||||
const handleViewProduct = () => {
|
||||
if (flashSale.value) {
|
||||
router.push(`/product/${flashSale.value.productId}`)
|
||||
}
|
||||
if (flashSale.value) router.push(`/product/${flashSale.value.productId}`)
|
||||
}
|
||||
|
||||
// 分享
|
||||
const handleShare = () => {
|
||||
ElMessage.success('分享链接已复制')
|
||||
// 实际实现复制链接到剪贴板
|
||||
const url = window.location.href
|
||||
navigator.clipboard.writeText(url)
|
||||
const handleShare = async () => {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
ElMessage.success('链接已复制,快去分享吧')
|
||||
}
|
||||
|
||||
// 倒计时结束
|
||||
const handleFinish = () => {
|
||||
loadFlashSaleDetail()
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFlashSaleDetail()
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -133,12 +133,11 @@ import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
|
||||
import ProductCard from '@/components/business/ProductCard.vue'
|
||||
import flashsaleApi from '@/api/flashsale'
|
||||
import productApi from '@/api/product'
|
||||
import { flashsaleApi } from '@/api/modules/flashsale'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { FlashSale } from '@/types/flashsale'
|
||||
import type { Product } from '@/types/product'
|
||||
import type { FlashSale, Product } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
@@ -151,7 +150,7 @@ const banners = [
|
||||
title: '秒杀系统',
|
||||
subtitle: '基于Redis集群构建的高并发秒杀系统',
|
||||
buttonText: '立即抢购',
|
||||
link: '/flashsale',
|
||||
link: '/flashsales',
|
||||
bgColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
icon: 'Lightning'
|
||||
},
|
||||
@@ -160,7 +159,7 @@ const banners = [
|
||||
title: '防超卖机制',
|
||||
subtitle: '采用分布式锁和Lua脚本,确保数据一致性',
|
||||
buttonText: '了解更多',
|
||||
link: '/flashsale',
|
||||
link: '/flashsales',
|
||||
bgColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
icon: 'Lock'
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="order-detail-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/" class="mb-6">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
|
||||
@@ -15,22 +14,16 @@
|
||||
|
||||
<div v-else-if="!order" class="text-center py-12">
|
||||
<el-empty description="订单不存在" />
|
||||
<el-button type="primary" @click="router.push('/orders')">
|
||||
返回订单列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="router.push('/orders')">返回订单列表</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 订单状态 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">订单状态</h2>
|
||||
<el-tag :type="getStatusType(order.status)" size="large">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
<el-tag :type="getStatusType(order.status)" size="large">{{ getStatusText(order.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 订单进度 -->
|
||||
<el-steps :active="getActiveStep()" finish-status="success">
|
||||
<el-step title="提交订单" :description="formatTime(order.createdAt)" />
|
||||
<el-step title="支付订单" :description="order.paidAt ? formatTime(order.paidAt) : ''" />
|
||||
@@ -38,130 +31,65 @@
|
||||
<el-step title="确认收货" :description="order.completedAt ? formatTime(order.completedAt) : ''" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-6 flex gap-2">
|
||||
<template v-if="order.status === 'PENDING'">
|
||||
<el-button type="primary" @click="handlePay">立即付款</el-button>
|
||||
<el-button @click="handleCancel">取消订单</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="order.status === 'SHIPPED'">
|
||||
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="order.status === 'COMPLETED'">
|
||||
<el-button @click="handleReview">评价</el-button>
|
||||
<el-button @click="handleRebuy">再次购买</el-button>
|
||||
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
||||
</template>
|
||||
<template v-else-if="order.status === 'CANCELLED'">
|
||||
<el-button text type="danger" @click="handleDelete">删除订单</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收货信息 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
|
||||
<div v-if="order.address" class="text-sm space-y-2">
|
||||
<div>
|
||||
<span class="text-gray-500">收货人:</span>
|
||||
<span>{{ order.address.name }} {{ order.address.phone }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">收货地址:</span>
|
||||
<span>
|
||||
{{ order.address.province }}
|
||||
{{ order.address.city }}
|
||||
{{ order.address.district }}
|
||||
{{ order.address.address }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500">
|
||||
暂无收货信息
|
||||
<div><span class="text-gray-500">收货人:</span><span>{{ order.address.name }} {{ order.address.phone }}</span></div>
|
||||
<div><span class="text-gray-500">收货地址:</span><span>{{ order.address.province }} {{ order.address.city }} {{ order.address.district }} {{ order.address.address }}</span></div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500">暂无收货信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 商品信息 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="flex gap-4 pb-4 border-b last:border-0"
|
||||
>
|
||||
<img
|
||||
:src="item.productImage || '/default-product.png'"
|
||||
:alt="item.productName"
|
||||
class="w-24 h-24 object-cover rounded"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-for="item in order.items" :key="item.id" class="flex gap-4 pb-4 border-b last:border-0">
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-24 h-24 rounded" img-class="w-24 h-24 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
|
||||
<div class="text-sm text-gray-500">
|
||||
单价:¥{{ item.price }} × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold text-lg">¥{{ item.subtotal }}</div>
|
||||
<div class="text-sm text-gray-500">单价:¥{{ item.price }} × {{ item.quantity }}</div>
|
||||
</div>
|
||||
<div class="text-right"><div class="font-semibold text-lg">¥{{ item.subtotal }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<div class="border-t pt-4 mt-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">商品总额</span>
|
||||
<span>¥{{ order.totalAmount }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">运费</span>
|
||||
<span>¥0.00</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">优惠</span>
|
||||
<span class="text-red-500">-¥{{ (order.totalAmount - order.paymentAmount).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between pt-2 border-t">
|
||||
<span class="font-semibold">实付金额</span>
|
||||
<span class="text-xl font-bold text-red-500">¥{{ order.paymentAmount }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm"><span class="text-gray-500">商品总额</span><span>¥{{ order.totalAmount }}</span></div>
|
||||
<div class="flex justify-between text-sm"><span class="text-gray-500">运费</span><span>¥0.00</span></div>
|
||||
<div class="flex justify-between text-sm"><span class="text-gray-500">优惠</span><span class="text-red-500">-¥0.00</span></div>
|
||||
<div class="flex justify-between text-lg font-semibold pt-2 border-t"><span>实付金额</span><span class="text-red-500">¥{{ order.paymentAmount }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">订单编号:</span>
|
||||
<span>{{ order.orderNo }}</span>
|
||||
<el-button text type="primary" size="small" @click="copyOrderNo">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">创建时间:</span>
|
||||
<span>{{ formatTime(order.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="order.paidAt">
|
||||
<span class="text-gray-500">付款时间:</span>
|
||||
<span>{{ formatTime(order.paidAt) }}</span>
|
||||
</div>
|
||||
<div v-if="order.paymentMethod">
|
||||
<span class="text-gray-500">支付方式:</span>
|
||||
<span>{{ getPaymentMethodText(order.paymentMethod) }}</span>
|
||||
</div>
|
||||
<div v-if="order.shippedAt">
|
||||
<span class="text-gray-500">发货时间:</span>
|
||||
<span>{{ formatTime(order.shippedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="order.completedAt">
|
||||
<span class="text-gray-500">完成时间:</span>
|
||||
<span>{{ formatTime(order.completedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="order.remark" class="md:col-span-2">
|
||||
<span class="text-gray-500">订单备注:</span>
|
||||
<span>{{ order.remark }}</span>
|
||||
</div>
|
||||
<div><span class="text-gray-500">订单编号:</span><span>{{ order.orderNo }}</span><el-button text type="primary" size="small" @click="copyOrderNo">复制</el-button></div>
|
||||
<div><span class="text-gray-500">创建时间:</span><span>{{ formatTime(order.createdAt) }}</span></div>
|
||||
<div v-if="order.paidAt"><span class="text-gray-500">付款时间:</span><span>{{ formatTime(order.paidAt) }}</span></div>
|
||||
<div v-if="order.paymentMethod"><span class="text-gray-500">支付方式:</span><span>{{ getPaymentMethodText(order.paymentMethod) }}</span></div>
|
||||
<div v-if="order.shippedAt"><span class="text-gray-500">发货时间:</span><span>{{ formatTime(order.shippedAt) }}</span></div>
|
||||
<div v-if="order.completedAt"><span class="text-gray-500">完成时间:</span><span>{{ formatTime(order.completedAt) }}</span></div>
|
||||
<div v-if="order.remark" class="md:col-span-2"><span class="text-gray-500">订单备注:</span><span>{{ order.remark }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,88 +102,40 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { orderApi } from '@/api/modules/order'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import type { Order } from '@/types/api'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDED: 'danger' }[status] || 'info')
|
||||
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDED: '已退款' }[status] || status)
|
||||
const getPaymentMethodText = (method: string) => ({ ONLINE: '在线支付', ALIPAY: '支付宝', WECHAT: '微信支付', CASH: '货到付款', default: '默认支付' }[method] || method)
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': 'warning',
|
||||
'PAID': 'primary',
|
||||
'SHIPPED': 'primary',
|
||||
'COMPLETED': 'success',
|
||||
'CANCELLED': 'info',
|
||||
'REFUNDED': 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待付款',
|
||||
'PAID': '待发货',
|
||||
'SHIPPED': '待收货',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消',
|
||||
'REFUNDED': '已退款'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 获取支付方式文本
|
||||
const getPaymentMethodText = (method: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ONLINE': '在线支付',
|
||||
'ALIPAY': '支付宝',
|
||||
'WECHAT': '微信支付',
|
||||
'CASH': '货到付款'
|
||||
}
|
||||
return map[method] || method
|
||||
}
|
||||
|
||||
// 获取当前步骤
|
||||
const getActiveStep = () => {
|
||||
if (!order.value) return 0
|
||||
|
||||
switch (order.value.status) {
|
||||
case 'PENDING': return 1
|
||||
case 'PAID': return 2
|
||||
case 'SHIPPED': return 3
|
||||
case 'COMPLETED': return 4
|
||||
case 'CANCELLED':
|
||||
case 'REFUNDED': return -1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 加载订单详情
|
||||
const loadOrderDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = Number(route.params.id)
|
||||
const res = await orderApi.getDetail(id)
|
||||
|
||||
if (res.success) {
|
||||
order.value = res.data
|
||||
}
|
||||
const res = await orderApi.getDetail(Number(route.params.id))
|
||||
if (res.success) order.value = res.data
|
||||
} catch (error) {
|
||||
console.error('加载订单详情失败:', error)
|
||||
ElMessage.error('加载失败')
|
||||
@@ -264,89 +144,58 @@ const loadOrderDetail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制订单号
|
||||
const copyOrderNo = () => {
|
||||
if (order.value) {
|
||||
navigator.clipboard.writeText(order.value.orderNo)
|
||||
ElMessage.success('订单号已复制')
|
||||
}
|
||||
if (!order.value) return
|
||||
navigator.clipboard.writeText(order.value.orderNo)
|
||||
ElMessage.success('订单号已复制')
|
||||
}
|
||||
|
||||
// 付款
|
||||
const handlePay = async () => {
|
||||
if (!order.value) return
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`订单金额:¥${order.value.paymentAmount},确认付款?`,
|
||||
'付款确认',
|
||||
{
|
||||
confirmButtonText: '确认付款',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await orderApi.pay(order.value.id, 'ONLINE')
|
||||
ElMessage.success('付款成功')
|
||||
loadOrderDetail()
|
||||
} catch (error) {
|
||||
console.error('付款失败:', error)
|
||||
}
|
||||
await ElMessageBox.confirm(`订单金额:¥${order.value.paymentAmount},确认付款?`, '付款确认', { confirmButtonText: '确认付款', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.pay(order.value.id, 'ONLINE'); ElMessage.success('付款成功'); loadOrderDetail() } catch (error) { console.error('付款失败:', error) }
|
||||
}
|
||||
|
||||
// 取消订单
|
||||
const handleCancel = async () => {
|
||||
if (!order.value) return
|
||||
|
||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
try {
|
||||
await orderApi.cancel(order.value.id)
|
||||
ElMessage.success('订单已取消')
|
||||
loadOrderDetail()
|
||||
} catch (error) {
|
||||
console.error('取消订单失败:', error)
|
||||
}
|
||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.cancel(order.value.id); ElMessage.success('订单已取消'); loadOrderDetail() } catch (error) { console.error('取消订单失败:', error) }
|
||||
}
|
||||
|
||||
// 确认收货
|
||||
const handleConfirm = async () => {
|
||||
if (!order.value) return
|
||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.confirm(order.value.id); ElMessage.success('已确认收货'); loadOrderDetail() } catch (error) { console.error('确认收货失败:', error) }
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!order.value) return
|
||||
const firstItem = order.value.items[0]
|
||||
if (!firstItem) return
|
||||
try {
|
||||
await orderApi.confirm(order.value.id)
|
||||
ElMessage.success('已确认收货')
|
||||
loadOrderDetail()
|
||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', { inputType: 'textarea', inputPlaceholder: '分享一下你的使用感受吧', confirmButtonText: '提交评价', cancelButtonText: '取消' })
|
||||
await reviewApi.create({ orderId: order.value.id, productId: firstItem.productId, rating: 5, content: value })
|
||||
ElMessage.success('评价提交成功')
|
||||
} catch (error) {
|
||||
console.error('确认收货失败:', error)
|
||||
if (error) console.error('提交评价失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 评价
|
||||
const handleReview = () => {
|
||||
ElMessage.info('评价功能开发中...')
|
||||
}
|
||||
|
||||
// 再次购买
|
||||
const handleRebuy = () => {
|
||||
ElMessage.success('商品已加入购物车')
|
||||
const handleRebuy = async () => {
|
||||
if (!order.value) return
|
||||
const firstItem = order.value.items[0]
|
||||
if (!firstItem) return
|
||||
await cartStore.addToCart(firstItem.productId, firstItem.quantity)
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrderDetail()
|
||||
})
|
||||
const handleDelete = async () => {
|
||||
if (!order.value) return
|
||||
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.delete(order.value.id); ElMessage.success('订单已删除'); router.push('/orders') } catch (error) { console.error('删除订单失败:', error) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadOrderDetail() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold flex items-center">
|
||||
<el-icon class="text-blue-500 mr-2"><List /></el-icon>
|
||||
@@ -9,26 +8,16 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 订单统计 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div
|
||||
v-for="stat in orderStats"
|
||||
:key="stat.key"
|
||||
class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow"
|
||||
@click="handleStatusFilter(stat.key)"
|
||||
>
|
||||
<el-icon :size="24" :class="stat.color" class="mb-2">
|
||||
<component :is="stat.icon" />
|
||||
</el-icon>
|
||||
<div v-for="stat in orderStats" :key="stat.key" class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow" @click="handleStatusFilter(stat.key)">
|
||||
<el-icon :size="24" :class="stat.color" class="mb-2"><component :is="stat.icon" /></el-icon>
|
||||
<div class="text-2xl font-bold">{{ stat.count }}</div>
|
||||
<div class="text-sm text-gray-500">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<!-- 状态筛选 -->
|
||||
<el-radio-group v-model="filters.status" @change="loadOrders">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="PENDING">待付款</el-radio-button>
|
||||
@@ -38,24 +27,14 @@
|
||||
<el-radio-button label="CANCELLED">已取消</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索订单号或商品名称"
|
||||
style="width: 250px"
|
||||
clearable
|
||||
@keyup.enter="loadOrders"
|
||||
>
|
||||
<el-input v-model="filters.keyword" placeholder="搜索订单号或商品名称" style="width: 250px" clearable @keyup.enter="loadOrders">
|
||||
<template #suffix>
|
||||
<el-icon class="cursor-pointer" @click="loadOrders">
|
||||
<Search />
|
||||
</el-icon>
|
||||
<el-icon class="cursor-pointer" @click="loadOrders"><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
@@ -63,118 +42,64 @@
|
||||
|
||||
<div v-else-if="orders.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||
<el-empty description="暂无订单">
|
||||
<el-button type="primary" @click="router.push('/products')">
|
||||
去购物
|
||||
</el-button>
|
||||
<el-button type="primary" @click="router.push('/products')">去购物</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 订单卡片 -->
|
||||
<div
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden"
|
||||
>
|
||||
<!-- 订单头部 -->
|
||||
<div v-for="order in orders" :key="order.id" class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>订单号:{{ order.orderNo }}</span>
|
||||
<span>{{ formatTime(order.createdAt) }}</span>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(order.status)">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
<el-tag :type="getStatusType(order.status)">{{ getStatusText(order.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 订单商品 -->
|
||||
<div class="p-6">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="flex gap-4 mb-4 last:mb-0"
|
||||
>
|
||||
<img
|
||||
:src="item.productImage || '/default-product.png'"
|
||||
:alt="item.productName"
|
||||
class="w-20 h-20 object-cover rounded"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-for="item in order.items" :key="item.id" class="flex gap-4 mb-4 last:mb-0">
|
||||
<SafeImage :src="item.productImage" :alt="item.productName" wrapper-class="w-20 h-20 rounded" img-class="w-20 h-20 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold">{{ item.productName }}</h4>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
¥{{ item.price }} × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold">¥{{ item.subtotal }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">¥{{ item.price }} × {{ item.quantity }}</div>
|
||||
</div>
|
||||
<div class="text-right"><div class="font-semibold">¥{{ item.subtotal }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单底部 -->
|
||||
<div class="border-t px-6 py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">实付金额:</span>
|
||||
<span class="text-xl font-bold text-red-500">
|
||||
¥{{ order.paymentAmount || order.totalAmount }}
|
||||
</span>
|
||||
<span class="text-xl font-bold text-red-500">¥{{ order.paymentAmount || order.totalAmount }}</span>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
@click="handleViewDetail(order.id)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button text type="primary" @click="handleViewDetail(order.id)">查看详情</el-button>
|
||||
|
||||
<!-- 根据状态显示不同操作 -->
|
||||
<template v-if="order.status === 'PENDING'">
|
||||
<el-button type="primary" size="small" @click="handlePay(order)">
|
||||
立即付款
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleCancel(order)">
|
||||
取消订单
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="handlePay(order)">立即付款</el-button>
|
||||
<el-button size="small" @click="handleCancel(order)">取消订单</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="order.status === 'SHIPPED'">
|
||||
<el-button type="primary" size="small" @click="handleConfirm(order)">
|
||||
确认收货
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="handleConfirm(order)">确认收货</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="order.status === 'COMPLETED'">
|
||||
<el-button size="small" @click="handleReview(order)">
|
||||
评价
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleRebuy(order)">
|
||||
再次购买
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleReview(order)">评价</el-button>
|
||||
<el-button size="small" @click="handleRebuy(order)">再次购买</el-button>
|
||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="order.status === 'CANCELLED' || order.status === 'REFUNDED'">
|
||||
<el-button text type="danger" size="small" @click="handleDelete(order)">
|
||||
删除订单
|
||||
</el-button>
|
||||
<template v-else-if="order.status === 'CANCELLED'">
|
||||
<el-button text type="danger" size="small" @click="handleDelete(order)">删除订单</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadOrders"
|
||||
@current-change="loadOrders"
|
||||
/>
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="loadOrders" @current-change="loadOrders" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,29 +110,21 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { orderApi } from '@/api/modules/order'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import type { Order } from '@/types/api'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
// 数据状态
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
keyword: ''
|
||||
})
|
||||
const filters = reactive({ status: '', keyword: '' })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 订单统计
|
||||
const orderStats = ref([
|
||||
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
|
||||
{ key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500' },
|
||||
@@ -217,186 +134,93 @@ const orderStats = ref([
|
||||
{ key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400' },
|
||||
])
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
const formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
const getStatusType = (status: string) => ({ PENDING: 'warning', PAID: 'primary', SHIPPED: 'primary', COMPLETED: 'success', CANCELLED: 'info', REFUNDED: 'danger' }[status] || 'info')
|
||||
const getStatusText = (status: string) => ({ PENDING: '待付款', PAID: '待发货', SHIPPED: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDED: '已退款' }[status] || status)
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': 'warning',
|
||||
'PAID': 'primary',
|
||||
'SHIPPED': 'primary',
|
||||
'COMPLETED': 'success',
|
||||
'CANCELLED': 'info',
|
||||
'REFUNDED': 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待付款',
|
||||
'PAID': '待发货',
|
||||
'SHIPPED': '待收货',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消',
|
||||
'REFUNDED': '已退款'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 加载订单列表
|
||||
const loadOrders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await orderApi.getList({
|
||||
...filters,
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size
|
||||
})
|
||||
|
||||
const res = await orderApi.getList({ page: pagination.page - 1, size: pagination.size, status: filters.status || undefined })
|
||||
if (res.success) {
|
||||
orders.value = res.data.content
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const list = res.data.content
|
||||
orders.value = keyword
|
||||
? list.filter((order) => order.orderNo.toLowerCase().includes(keyword) || order.items.some((item) => item.productName.toLowerCase().includes(keyword)))
|
||||
: list
|
||||
pagination.total = res.data.totalElements
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载订单失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载订单统计
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
const res = await orderApi.getStatistics()
|
||||
if (res.success) {
|
||||
const stats = res.data
|
||||
orderStats.value[0].count = stats.total
|
||||
orderStats.value[1].count = stats.pending
|
||||
orderStats.value[2].count = stats.paid
|
||||
orderStats.value[3].count = stats.shipped
|
||||
orderStats.value[4].count = stats.completed
|
||||
orderStats.value[5].count = stats.cancelled
|
||||
orderStats.value[0].count = res.data.total
|
||||
orderStats.value[1].count = res.data.pending
|
||||
orderStats.value[2].count = res.data.paid
|
||||
orderStats.value[3].count = res.data.shipped
|
||||
orderStats.value[4].count = res.data.completed
|
||||
orderStats.value[5].count = res.data.cancelled
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
const handleStatusFilter = (status: string) => {
|
||||
filters.status = status
|
||||
pagination.page = 1
|
||||
loadOrders()
|
||||
const handleStatusFilter = (status: string) => { filters.status = status; pagination.page = 1; loadOrders() }
|
||||
const handleViewDetail = (orderId: number) => router.push(`/order/${orderId}`)
|
||||
|
||||
const handlePay = async (order: Order) => {
|
||||
await ElMessageBox.confirm(`订单金额:¥${order.paymentAmount || order.totalAmount},确认付款?`, '付款确认', { confirmButtonText: '确认付款', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.pay(order.id, 'ONLINE'); ElMessage.success('付款成功'); loadOrders(); loadStatistics() } catch (error) { console.error('付款失败:', error) }
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (orderId: number) => {
|
||||
router.push(`/order/${orderId}`)
|
||||
}
|
||||
|
||||
// 付款
|
||||
const handlePay = (order: Order) => {
|
||||
ElMessageBox.confirm(
|
||||
`订单金额:¥${order.totalAmount},确认付款?`,
|
||||
'付款确认',
|
||||
{
|
||||
confirmButtonText: '确认付款',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await orderApi.pay(order.id, 'ONLINE')
|
||||
ElMessage.success('付款成功')
|
||||
loadOrders()
|
||||
loadStatistics()
|
||||
} catch (error) {
|
||||
console.error('付款失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消订单
|
||||
const handleCancel = async (order: Order) => {
|
||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
try {
|
||||
await orderApi.cancel(order.id)
|
||||
ElMessage.success('订单已取消')
|
||||
loadOrders()
|
||||
loadStatistics()
|
||||
} catch (error) {
|
||||
console.error('取消订单失败:', error)
|
||||
}
|
||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.cancel(order.id); ElMessage.success('订单已取消'); loadOrders(); loadStatistics() } catch (error) { console.error('取消订单失败:', error) }
|
||||
}
|
||||
|
||||
// 确认收货
|
||||
const handleConfirm = async (order: Order) => {
|
||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await ElMessageBox.confirm('确定已收到商品?', '确认收货', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.confirm(order.id); ElMessage.success('已确认收货'); loadOrders(); loadStatistics() } catch (error) { console.error('确认收货失败:', error) }
|
||||
}
|
||||
|
||||
const handleReview = async (order: Order) => {
|
||||
const firstItem = order.items[0]
|
||||
if (!firstItem) return
|
||||
|
||||
try {
|
||||
await orderApi.confirm(order.id)
|
||||
ElMessage.success('已确认收货')
|
||||
loadOrders()
|
||||
loadStatistics()
|
||||
const { value } = await ElMessageBox.prompt('请输入本次购物评价', '商品评价', {
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '分享一下你的使用感受吧',
|
||||
confirmButtonText: '提交评价',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await reviewApi.create({ orderId: order.id, productId: firstItem.productId, rating: 5, content: value })
|
||||
ElMessage.success('评价提交成功')
|
||||
} catch (error) {
|
||||
console.error('确认收货失败:', error)
|
||||
if (error) console.error('提交评价失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 评价
|
||||
const handleReview = (order: Order) => {
|
||||
ElMessage.info('评价功能开发中...')
|
||||
}
|
||||
|
||||
// 再次购买
|
||||
const handleRebuy = (order: Order) => {
|
||||
// 将商品重新加入购物车
|
||||
ElMessage.success('商品已加入购物车')
|
||||
const handleRebuy = async (order: Order) => {
|
||||
const firstItem = order.items[0]
|
||||
if (!firstItem) return
|
||||
await cartStore.addToCart(firstItem.productId, firstItem.quantity)
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
const handleDelete = async (order: Order) => {
|
||||
await ElMessageBox.confirm('确定要删除该订单吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
try {
|
||||
await orderApi.delete(order.id)
|
||||
ElMessage.success('订单已删除')
|
||||
loadOrders()
|
||||
loadStatistics()
|
||||
} catch (error) {
|
||||
console.error('删除订单失败:', error)
|
||||
}
|
||||
await ElMessageBox.confirm('确定删除该订单吗?删除后不可恢复。', '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
try { await orderApi.delete(order.id); ElMessage.success('订单已删除'); loadOrders(); loadStatistics() } catch (error) { console.error('删除订单失败:', error) }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrders()
|
||||
loadStatistics()
|
||||
})
|
||||
onMounted(() => { loadOrders(); loadStatistics() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -25,12 +25,7 @@
|
||||
<!-- 左侧:商品图片 -->
|
||||
<div>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="currentImage || '/default-product.png'"
|
||||
:alt="product.name"
|
||||
class="w-full rounded-lg"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<SafeImage :src="currentImage" :alt="product?.name || '商品图片'" wrapper-class="w-full h-[420px] rounded-2xl overflow-hidden bg-gray-100" img-class="w-full h-[420px] object-cover" />
|
||||
|
||||
<!-- 商品状态 -->
|
||||
<div v-if="product.stock === 0" class="absolute top-4 right-4">
|
||||
@@ -140,6 +135,10 @@
|
||||
<dt class="text-gray-500">商品分类</dt>
|
||||
<dd class="font-medium">{{ product.category }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">用户评分</dt>
|
||||
<dd class="font-medium">{{ reviewSummary.averageRating.toFixed(1) }} / 5({{ reviewSummary.totalReviews }} 条)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">上架时间</dt>
|
||||
<dd class="font-medium">{{ formatTime(product.createdAt) }}</dd>
|
||||
@@ -162,6 +161,33 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户评价" name="reviews">
|
||||
<div class="py-6">
|
||||
<div class="mb-4 flex items-center justify-between bg-gray-50 rounded-lg p-4">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-yellow-500">{{ reviewSummary.averageRating.toFixed(1) }}</div>
|
||||
<div class="text-sm text-gray-500">累计 {{ reviewSummary.totalReviews }} 条评价</div>
|
||||
</div>
|
||||
<el-rate :model-value="reviewSummary.averageRating" disabled show-score text-color="#f59e0b" />
|
||||
</div>
|
||||
<div v-if="reviewSummary.reviews.length > 0" class="space-y-4">
|
||||
<div v-for="review in reviewSummary.reviews" :key="review.id" class="border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="font-semibold">{{ review.username }}</div>
|
||||
<div class="text-sm text-gray-400">{{ formatTime(review.createdAt) }}</div>
|
||||
</div>
|
||||
<el-rate :model-value="review.rating" disabled />
|
||||
<p class="text-gray-600 mt-3 leading-6">{{ review.content }}</p>
|
||||
<div v-if="review.adminReply" class="mt-3 rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
|
||||
<div class="font-medium text-gray-800 mb-1">商家回复</div>
|
||||
<div>{{ review.adminReply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无评价" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="规格参数" name="specs">
|
||||
<div class="py-6">
|
||||
<p class="text-gray-500">暂无规格参数</p>
|
||||
@@ -190,10 +216,14 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { productApi } from '@/api/modules/product'
|
||||
import { reviewApi } from '@/api/modules/review'
|
||||
import { favoriteApi } from '@/api/modules/favorite'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { Product } from '@/types/api'
|
||||
import dayjs from 'dayjs'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
import { DEFAULT_PRODUCT_IMAGE, resolveImageUrl } from '@/utils/image'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -206,6 +236,8 @@ const currentImage = ref('')
|
||||
const quantity = ref(1)
|
||||
const activeTab = ref('detail')
|
||||
const isFavorited = ref(false)
|
||||
const reviewSummary = ref({ averageRating: 0, totalReviews: 0, reviews: [] as Array<{ id: number; username: string; rating: number; content: string; adminReply?: string; createdAt: string }> })
|
||||
const defaultProductImage = DEFAULT_PRODUCT_IMAGE
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
@@ -213,10 +245,6 @@ const formatTime = (time: string) => {
|
||||
}
|
||||
|
||||
// 处理图片错误
|
||||
const handleImageError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/default-product.png'
|
||||
}
|
||||
|
||||
// 加载商品详情
|
||||
const loadProductDetail = async () => {
|
||||
@@ -227,7 +255,14 @@ const loadProductDetail = async () => {
|
||||
|
||||
if (res.success) {
|
||||
product.value = res.data
|
||||
currentImage.value = res.data.imageUrl || res.data.images?.[0] || ''
|
||||
currentImage.value = resolveImageUrl(res.data.imageUrl || res.data.images?.[0] || '')
|
||||
await loadReviews(res.data.id)
|
||||
if (userStore.isLoggedIn) {
|
||||
const favoriteRes = await favoriteApi.check(res.data.id)
|
||||
if (favoriteRes.success) {
|
||||
isFavorited.value = favoriteRes.data.favorited
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商品详情失败:', error)
|
||||
@@ -237,6 +272,17 @@ const loadProductDetail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadReviews = async (productId: number) => {
|
||||
try {
|
||||
const res = await reviewApi.getProductReviews(productId)
|
||||
if (res.success) {
|
||||
reviewSummary.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评价失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加入购物车
|
||||
const handleAddToCart = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
@@ -278,7 +324,7 @@ const handleBuyNow = async () => {
|
||||
}
|
||||
|
||||
// 收藏/取消收藏
|
||||
const handleFavorite = () => {
|
||||
const handleFavorite = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push({
|
||||
@@ -288,8 +334,13 @@ const handleFavorite = () => {
|
||||
return
|
||||
}
|
||||
|
||||
isFavorited.value = !isFavorited.value
|
||||
ElMessage.success(isFavorited.value ? '已收藏' : '已取消收藏')
|
||||
if (!product.value) return
|
||||
|
||||
const res = await favoriteApi.toggle(product.value.id)
|
||||
if (res.success) {
|
||||
isFavorited.value = res.data.favorited
|
||||
ElMessage.success(isFavorited.value ? '已收藏' : '已取消收藏')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -168,7 +168,7 @@ const loadProducts = async () => {
|
||||
params.sort = 'price'
|
||||
params.order = 'desc'
|
||||
} else if (filters.sort === 'sales') {
|
||||
params.sort = 'sales'
|
||||
params.sort = 'createdAt'
|
||||
params.order = 'desc'
|
||||
}
|
||||
|
||||
|
||||
106
flash-sale-frontend/src/pages/user/favorites.vue
Normal file
106
flash-sale-frontend/src/pages/user/favorites.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2 flex items-center">
|
||||
<el-icon class="text-pink-500 mr-2"><StarFilled /></el-icon>
|
||||
我的收藏
|
||||
</h1>
|
||||
<p class="text-gray-600">收藏你感兴趣的商品,随时回来查看</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
|
||||
<p class="mt-2 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="favorites.length === 0" class="bg-white rounded-lg shadow-sm p-12">
|
||||
<el-empty description="暂无收藏商品">
|
||||
<el-button type="primary" @click="router.push('/products')">去逛逛</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="item in favorites" :key="item.id" class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
|
||||
<SafeImage
|
||||
:src="item.productImageUrl"
|
||||
:alt="item.productName"
|
||||
wrapper-class="w-full h-48 bg-gray-100"
|
||||
img-class="w-full h-48 object-cover"
|
||||
:clickable="true"
|
||||
@click="router.push(`/product/${item.productId}`)"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold mb-2 line-clamp-1">{{ item.productName }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">{{ item.productCategory || '默认分类' }}</p>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-xl font-bold text-red-500">¥{{ item.productPrice }}</span>
|
||||
<span class="text-xs text-gray-400">收藏于 {{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" class="flex-1" @click="addToCart(item.productId)">加入购物车</el-button>
|
||||
<el-button @click="toggleFavorite(item.productId)">取消收藏</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { favoriteApi, type FavoriteItem } from '@/api/modules/favorite'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import SafeImage from '@/components/common/SafeImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
const loading = ref(false)
|
||||
const favorites = ref<FavoriteItem[]>([])
|
||||
|
||||
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD')
|
||||
|
||||
const loadFavorites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await favoriteApi.getList()
|
||||
if (res.success) favorites.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addToCart = async (productId: number) => {
|
||||
await cartStore.addToCart(productId)
|
||||
}
|
||||
|
||||
const toggleFavorite = async (productId: number) => {
|
||||
const res = await favoriteApi.toggle(productId)
|
||||
if (res.success) {
|
||||
ElMessage.success(res.data.favorited ? '收藏成功' : '已取消收藏')
|
||||
loadFavorites()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.favorites-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -86,8 +86,8 @@
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-900 mb-2">测试账号</h3>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p>普通用户: user / 123456</p>
|
||||
<p>管理员: admin / 123456</p>
|
||||
<p>普通用户: demo1 / 123456</p>
|
||||
<p>管理员: admin / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,13 +96,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElForm } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { LoginParams } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
@@ -147,11 +144,11 @@ const handleLogin = async () => {
|
||||
// 快速登录
|
||||
const quickLogin = (type: 'user' | 'admin') => {
|
||||
if (type === 'user') {
|
||||
loginForm.username = 'user'
|
||||
loginForm.username = 'demo1'
|
||||
loginForm.password = '123456'
|
||||
} else {
|
||||
loginForm.username = 'admin'
|
||||
loginForm.password = '123456'
|
||||
loginForm.password = 'admin123'
|
||||
}
|
||||
loginForm.rememberMe = true
|
||||
handleLogin()
|
||||
|
||||
@@ -1,303 +1,325 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-value">{{ profileStats.totalOrders }}</div>
|
||||
<div class="stat-label">总订单数</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-value">¥{{ Number(profileStats.totalAmount || 0).toFixed(2) }}</div>
|
||||
<div class="stat-label">累计消费</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-value">{{ profileStats.flashSaleSuccess }}</div>
|
||||
<div class="stat-label">秒杀成功</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-value">{{ profileStats.favoriteCount }}</div>
|
||||
<div class="stat-label">收藏商品</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- 左侧菜单 -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 用户头像 -->
|
||||
<div class="text-center mb-6">
|
||||
<el-avatar :size="80" :src="userStore.user?.avatar">
|
||||
{{ userStore.username[0] }}
|
||||
<el-avatar :size="80" :src="infoForm.avatar">
|
||||
{{ (userStore.username || 'U')[0] }}
|
||||
</el-avatar>
|
||||
<h3 class="mt-3 font-semibold">{{ userStore.username }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-menu-item index="info">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>基本信息</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="security">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>账号安全</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="address">
|
||||
<el-icon><Location /></el-icon>
|
||||
<span>收货地址</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="orders">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>我的订单</span>
|
||||
</el-menu-item>
|
||||
<el-menu :default-active="activeMenu" @select="handleMenuSelect">
|
||||
<el-menu-item index="info"><el-icon><User /></el-icon><span>基本信息</span></el-menu-item>
|
||||
<el-menu-item index="security"><el-icon><Lock /></el-icon><span>账号安全</span></el-menu-item>
|
||||
<el-menu-item index="address"><el-icon><Location /></el-icon><span>收货地址</span></el-menu-item>
|
||||
<el-menu-item index="orders"><el-icon><List /></el-icon><span>我的订单</span></el-menu-item>
|
||||
<el-menu-item index="favorites"><el-icon><Star /></el-icon><span>我的收藏</span></el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 基本信息 -->
|
||||
<div v-if="activeMenu === 'info'">
|
||||
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
|
||||
|
||||
<el-form
|
||||
ref="infoFormRef"
|
||||
:model="infoForm"
|
||||
:rules="infoRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="infoForm.username" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="infoForm.email" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="infoForm.phone" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form ref="infoFormRef" :model="infoForm" :rules="infoRules" label-width="100px">
|
||||
<el-form-item label="用户名"><el-input v-model="infoForm.username" disabled /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model="infoForm.email" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phone"><el-input v-model="infoForm.phone" /></el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<div class="flex items-center gap-4">
|
||||
<el-avatar :size="60" :src="infoForm.avatar">
|
||||
{{ infoForm.username[0] }}
|
||||
</el-avatar>
|
||||
<el-button>更换头像</el-button>
|
||||
<el-avatar :size="60" :src="infoForm.avatar">{{ (infoForm.username || 'U')[0] }}</el-avatar>
|
||||
<el-button @click="handleUpdateAvatar">更换头像</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSaveInfo">
|
||||
保存修改
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="handleSaveInfo">保存修改</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 账号安全 -->
|
||||
<div v-else-if="activeMenu === 'security'">
|
||||
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
|
||||
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="原密码" prop="oldPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleChangePassword">
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
|
||||
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="passwordForm.oldPassword" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="passwordForm.newPassword" type="password" show-password /></el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="handleChangePassword">修改密码</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 收货地址 -->
|
||||
<div v-else-if="activeMenu === 'address'">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">收货地址</h2>
|
||||
<el-button type="primary" @click="handleAddAddress">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
添加地址
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openAddressDialog()"><el-icon class="mr-1"><Plus /></el-icon>添加地址</el-button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="addr in addresses"
|
||||
:key="addr.id"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div v-for="addr in addresses" :key="addr.id" class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="font-semibold">{{ addr.name }}</span>
|
||||
<span class="text-gray-500">{{ addr.phone }}</span>
|
||||
<el-tag v-if="addr.isDefault" type="primary" size="small">
|
||||
默认
|
||||
</el-tag>
|
||||
<el-tag v-if="addr.isDefault" type="primary" size="small">默认</el-tag>
|
||||
</div>
|
||||
<p class="text-gray-600">
|
||||
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
|
||||
</p>
|
||||
<p class="text-gray-600">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}</p>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<el-button text type="primary" size="small">编辑</el-button>
|
||||
<el-button text type="danger" size="small">删除</el-button>
|
||||
<div class="space-x-2 whitespace-nowrap">
|
||||
<el-button v-if="!addr.isDefault" text type="success" size="small" @click="setDefaultAddress(addr.id)">设为默认</el-button>
|
||||
<el-button text type="primary" size="small" @click="openAddressDialog(addr)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="removeAddress(addr.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="addresses.length === 0" description="暂无收货地址" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的订单 -->
|
||||
<div v-else-if="activeMenu === 'orders'">
|
||||
<h2 class="text-xl font-semibold mb-6">我的订单</h2>
|
||||
<p class="text-gray-500">请访问 <router-link to="/orders" class="text-primary-500">订单页面</router-link> 查看详细订单信息</p>
|
||||
<p class="text-gray-500">正在跳转到订单列表页...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeMenu === 'favorites'">
|
||||
<h2 class="text-xl font-semibold mb-6">我的收藏</h2>
|
||||
<p class="text-gray-500">正在跳转到收藏页...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="addressDialogVisible" :title="editingAddressId ? '编辑地址' : '新增地址'" width="620px">
|
||||
<el-form ref="addressFormRef" :model="addressForm" :rules="addressRules" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="收货人" prop="name"><el-input v-model="addressForm.name" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="手机号" prop="phone"><el-input v-model="addressForm.phone" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="省份" prop="province"><el-input v-model="addressForm.province" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="城市" prop="city"><el-input v-model="addressForm.city" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="区县" prop="district"><el-input v-model="addressForm.district" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="详细地址" prop="address"><el-input v-model="addressForm.address" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item><el-checkbox v-model="addressForm.isDefault">设为默认地址</el-checkbox></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addressDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAddress">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElForm } from 'element-plus'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { userApi } from '@/api/modules/user'
|
||||
import { addressApi, type AddressItem } from '@/api/modules/address'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activeMenu = ref('info')
|
||||
const infoFormRef = ref<FormInstance>()
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
const addressFormRef = ref<FormInstance>()
|
||||
const addressDialogVisible = ref(false)
|
||||
const editingAddressId = ref<number | null>(null)
|
||||
const addresses = ref<AddressItem[]>([])
|
||||
const profileStats = reactive({ totalOrders: 0, totalAmount: 0, flashSaleSuccess: 0, favoriteCount: 0 })
|
||||
|
||||
// 基本信息表单
|
||||
const infoForm = reactive({
|
||||
username: userStore.user?.username || '',
|
||||
email: userStore.user?.email || '',
|
||||
phone: userStore.user?.phone || '',
|
||||
avatar: userStore.user?.avatar || ''
|
||||
})
|
||||
const infoForm = reactive({ username: '', email: '', phone: '', avatar: '' })
|
||||
const passwordForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const addressForm = reactive<AddressItem>({ id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
|
||||
|
||||
const infoRules: FormRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
]
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 修改密码表单
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validatePassword = (rule: any, value: any, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== passwordForm.newPassword) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
|
||||
if (!value) callback(new Error('请再次输入密码'))
|
||||
else if (value !== passwordForm.newPassword) callback(new Error('两次输入密码不一致'))
|
||||
else callback()
|
||||
}
|
||||
|
||||
const passwordRules: FormRules = {
|
||||
oldPassword: [
|
||||
{ required: true, message: '请输入原密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, validator: validatePassword, trigger: 'blur' }
|
||||
]
|
||||
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }],
|
||||
confirmPassword: [{ required: true, validator: validatePassword, trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 收货地址
|
||||
const addresses = ref<any[]>([])
|
||||
const addressRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
|
||||
phone: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确手机号', trigger: 'blur' }],
|
||||
province: [{ required: true, message: '请输入省份', trigger: 'blur' }],
|
||||
city: [{ required: true, message: '请输入城市', trigger: 'blur' }],
|
||||
district: [{ required: true, message: '请输入区县', trigger: 'blur' }],
|
||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 菜单选择
|
||||
const handleMenuSelect = (index: string) => {
|
||||
activeMenu.value = index
|
||||
if (index === 'orders') {
|
||||
router.push('/orders')
|
||||
const syncInfoForm = () => {
|
||||
infoForm.username = userStore.user?.username || ''
|
||||
infoForm.email = userStore.user?.email || ''
|
||||
infoForm.phone = userStore.user?.phone || ''
|
||||
infoForm.avatar = userStore.user?.avatar || ''
|
||||
}
|
||||
|
||||
const syncActiveMenu = () => {
|
||||
if (route.path.endsWith('/addresses')) activeMenu.value = 'address'
|
||||
else activeMenu.value = (route.query.tab as string) || 'info'
|
||||
}
|
||||
|
||||
const loadAddresses = async () => {
|
||||
try {
|
||||
const res = await addressApi.getList()
|
||||
if (res.success) addresses.value = res.data || []
|
||||
} catch (error) {
|
||||
console.error('加载地址失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存基本信息
|
||||
const loadProfileStats = async () => {
|
||||
try {
|
||||
const res = await userApi.getProfileStats()
|
||||
if (res.success) Object.assign(profileStats, res.data)
|
||||
} catch (error) {
|
||||
console.error('加载个人中心统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetAddressForm = () => {
|
||||
editingAddressId.value = null
|
||||
Object.assign(addressForm, { id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
|
||||
}
|
||||
|
||||
const handleMenuSelect = (index: string) => {
|
||||
activeMenu.value = index
|
||||
if (index === 'orders') router.push('/orders')
|
||||
else if (index === 'favorites') router.push('/favorites')
|
||||
else if (index === 'address') router.push('/addresses')
|
||||
else router.push({ path: '/profile', query: { tab: index } })
|
||||
}
|
||||
|
||||
const handleSaveInfo = async () => {
|
||||
if (!infoFormRef.value) return
|
||||
|
||||
await infoFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const res = await userApi.updateInfo(infoForm)
|
||||
if (res.success) {
|
||||
userStore.updateUserInfo(infoForm)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
if (!valid) return
|
||||
try {
|
||||
const res = await userApi.updateInfo({ email: infoForm.email, phone: infoForm.phone, avatar: infoForm.avatar })
|
||||
if (res.success) {
|
||||
userStore.updateUserInfo(res.data)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const handleUpdateAvatar = async () => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入新的头像图片 URL', '更换头像', { inputValue: infoForm.avatar, confirmButtonText: '保存', cancelButtonText: '取消' })
|
||||
infoForm.avatar = value || ''
|
||||
userStore.updateUserInfo({ avatar: infoForm.avatar })
|
||||
ElMessage.success('头像已更新')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
|
||||
await passwordFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const res = await userApi.changePassword({
|
||||
oldPassword: passwordForm.oldPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
userStore.logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
if (!valid) return
|
||||
try {
|
||||
const res = await userApi.changePassword({ oldPassword: passwordForm.oldPassword, newPassword: passwordForm.newPassword, confirmPassword: passwordForm.confirmPassword })
|
||||
if (res.success) {
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
await userStore.logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加地址
|
||||
const handleAddAddress = () => {
|
||||
ElMessage.info('功能开发中...')
|
||||
const openAddressDialog = (item?: AddressItem) => {
|
||||
resetAddressForm()
|
||||
if (item) {
|
||||
editingAddressId.value = item.id
|
||||
Object.assign(addressForm, item)
|
||||
}
|
||||
addressDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载用户信息
|
||||
userStore.getUserInfo()
|
||||
const saveAddress = async () => {
|
||||
if (!addressFormRef.value) return
|
||||
await addressFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
const payload = { name: addressForm.name, phone: addressForm.phone, province: addressForm.province, city: addressForm.city, district: addressForm.district, address: addressForm.address, isDefault: addressForm.isDefault }
|
||||
try {
|
||||
if (editingAddressId.value) {
|
||||
await addressApi.update(editingAddressId.value, payload)
|
||||
ElMessage.success('地址已更新')
|
||||
} else {
|
||||
await addressApi.create(payload)
|
||||
ElMessage.success('地址已新增')
|
||||
}
|
||||
addressDialogVisible.value = false
|
||||
loadAddresses()
|
||||
} catch (error) {
|
||||
console.error('保存地址失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setDefaultAddress = async (id: number) => {
|
||||
await addressApi.setDefault(id)
|
||||
ElMessage.success('默认地址已更新')
|
||||
loadAddresses()
|
||||
}
|
||||
|
||||
const removeAddress = async (id: number) => {
|
||||
await ElMessageBox.confirm('确定删除这个地址吗?', '提示', { type: 'warning' })
|
||||
await addressApi.delete(id)
|
||||
ElMessage.success('地址已删除')
|
||||
loadAddresses()
|
||||
}
|
||||
|
||||
watch(() => userStore.user, syncInfoForm, { immediate: true })
|
||||
watch(() => route.fullPath, syncActiveMenu, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.token) await userStore.getUserInfo()
|
||||
syncInfoForm()
|
||||
syncActiveMenu()
|
||||
loadAddresses()
|
||||
loadProfileStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -306,4 +328,21 @@ onMounted(() => {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-lg p-5 text-white shadow-sm;
|
||||
|
||||
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
&.green { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm mt-2 opacity-90;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user