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