feat: 前端页面和组件全面完善

- 优化通用组件:导航栏、页脚、图片上传、搜索
- 完善业务组件:商品卡片、秒杀卡片
- 更新用户端页面:首页、商品、秒杀、订单、购物车、个人中心
- 新增用户收藏页面
- 完善管理后台:仪表盘、商品/订单/用户/秒杀管理
- 新增管理后台:收藏管理、评价管理、系统监控页面
This commit is contained in:
2026-03-10 23:21:53 +08:00
parent abba469a20
commit c52d9c52e3
25 changed files with 3409 additions and 1467 deletions

View File

@@ -1,77 +1,44 @@
<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>
{{ statusText }}
</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>
@@ -167,7 +98,7 @@ const handleParticipate = async () => {
.flash-sale-card {
@apply bg-white rounded-lg overflow-hidden;
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
}
@@ -176,4 +107,4 @@ const handleParticipate = async () => {
.discount-badge {
@apply px-2 py-1 bg-orange-500 text-white text-xs font-bold rounded;
}
</style>
</style>

View File

@@ -1,53 +1,35 @@
<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 }}
</el-tag>
</div>
</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}`)
}
@@ -92,7 +61,7 @@ const handleViewDetail = () => {
.product-card {
@apply bg-white rounded-lg overflow-hidden;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
}
@@ -104,4 +73,4 @@ const handleViewDetail = () => {
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</style>

View File

@@ -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>

View File

@@ -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()
}
// 更新购物车数量
@@ -183,4 +165,4 @@ onMounted(() => {
background-color: var(--primary-color);
}
}
</style>
</style>

View File

@@ -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 {
@@ -336,4 +339,4 @@ const updateValue = () => {
}
}
}
</style>
</style>

View File

@@ -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({
@@ -259,28 +259,38 @@ const fetchSuggestions = debounce(async () => {
suggestions.value = []
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>

View File

@@ -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>
@@ -303,4 +321,4 @@ const handleLogout = async () => {
opacity: 0;
transform: translateX(30px);
}
</style>
</style>

View File

@@ -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
salesChart = echarts.init(salesChartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis'
},
if (!salesChart) {
salesChart = echarts.init(salesChartRef.value)
}
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'
},
if (!categoryChart) {
categoryChart = echarts.init(categoryChartRef.value)
}
categoryChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
name: '商品分类',
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
},
radius: ['42%', '72%'],
data: [
{ value: 35, name: '电子产品' },
{ value: 28, name: '服装鞋包' },
{ value: 20, name: '食品饮料' },
{ value: 10, name: '图书音像' },
{ value: 7, name: '其他' }
]
}
]
}
categoryChart.setOption(option)
{ 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>
</style>

View 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>

View File

@@ -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-title {
@apply text-2xl font-bold;
}
.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;
}
.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>
</style>

View 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>

View File

@@ -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-title {
@apply text-2xl font-bold;
}
.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;
}
.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>
</style>

View File

@@ -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-title {
@apply text-2xl font-bold;
}
.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;
}
.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;
}
}
</style>
.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>

View 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>

View File

@@ -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-title {
@apply text-2xl font-bold;
}
.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;
}
.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>
</style>

View File

@@ -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 = () => {
@@ -304,9 +294,18 @@ const handleCheckout = async () => {
ElMessage.warning('请选择要结算的商品')
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)
}
}
// 加载推荐商品
@@ -339,4 +338,4 @@ onMounted(() => {
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</style>

View File

@@ -1,38 +1,32 @@
<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>
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<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="!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>
@@ -41,104 +35,70 @@
</div>
</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>
<span>每人限购 {{ flashSale.limitPerUser }} </span>
</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>
@@ -331,4 +245,4 @@ onMounted(() => {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>
</style>

View File

@@ -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'
},
@@ -258,4 +257,4 @@ onMounted(() => {
:deep(.el-carousel__item) {
overflow: hidden;
}
</style>
</style>

View File

@@ -1,167 +1,95 @@
<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>
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
</el-breadcrumb>
<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="!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) : ''" />
<el-step title="商家发货" :description="order.shippedAt ? formatTime(order.shippedAt) : ''" />
<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',
})
await ElMessageBox.confirm('确定已收到商品?', '确认收货', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
try { await orderApi.confirm(order.value.id); ElMessage.success('已确认收货'); loadOrderDetail() } catch (error) { console.error('确认收货失败:', error) }
}
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">
@@ -354,4 +203,4 @@ onMounted(() => {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>
</style>

View File

@@ -1,34 +1,23 @@
<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>
我的订单
</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>
@@ -37,144 +26,80 @@
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<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>
</div>
<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">
@@ -404,4 +228,4 @@ onMounted(() => {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>
</style>

View File

@@ -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({
@@ -287,9 +333,14 @@ 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(() => {

View File

@@ -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'
}

View 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>

View File

@@ -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()
@@ -162,4 +159,4 @@ const quickLogin = (type: 'user' | 'admin') => {
.login-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</style>

View File

@@ -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;
}
</style>
.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>