feat: 前端页面和组件全面完善
- 优化通用组件:导航栏、页脚、图片上传、搜索 - 完善业务组件:商品卡片、秒杀卡片 - 更新用户端页面:首页、商品、秒杀、订单、购物车、个人中心 - 新增用户收藏页面 - 完善管理后台:仪表盘、商品/订单/用户/秒杀管理 - 新增管理后台:收藏管理、评价管理、系统监控页面
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user