Files
FlashSaleSystem/flash-sale-frontend/src/pages/admin/flashsales.vue
2026-05-02 17:45:58 +08:00

585 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-flashsales page-shell">
<div class="page-header">
<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="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="PAUSED" />
<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 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 label="活动价" prop="flashPrice" 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 === 'PAUSED'" text type="success" @click="changeStatus('resume', row)">恢复</el-button>
<el-button v-if="row.status === 'ACTIVE' || row.status === 'PAUSED'" 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"
:disabled-date="disablePastDate"
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"
:disabled-date="disablePastDate"
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 TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const CREATE_START_LEAD_MINUTES = 5
const CREATE_DURATION_DAYS = 1
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 buildDefaultStartTime = () => dayjs().add(CREATE_START_LEAD_MINUTES, 'minute').startOf('minute').format(TIME_FORMAT)
const buildDefaultEndTime = (startTime = buildDefaultStartTime()) => dayjs(startTime).add(CREATE_DURATION_DAYS, 'day').format(TIME_FORMAT)
const form = reactive({
id: 0,
productId: undefined as number | undefined,
flashPrice: 0.01,
flashStock: 1,
startTime: buildDefaultStartTime(),
endTime: buildDefaultEndTime(),
})
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: '进行中',
PAUSED: '已暂停',
ENDED: '已结束',
}
return map[status] || status
}
const getStatusType = (status: string) => {
const map: Record<string, string> = {
UPCOMING: 'warning',
ACTIVE: 'danger',
PAUSED: 'warning',
ENDED: 'info',
}
return map[status] || 'info'
}
const getStockRate = (item: FlashSale) => {
if (!item.flashStock) return 0
return Math.round((item.remainingStock / item.flashStock) * 100)
}
const disablePastDate = (date: Date) => {
if (formMode.value === 'edit') return false
return dayjs(date).endOf('day').isBefore(dayjs())
}
const validateTimeRange = () => {
const startTime = dayjs(form.startTime)
const endTime = dayjs(form.endTime)
if (!startTime.isValid() || !endTime.isValid()) {
ElMessage.error('开始时间或结束时间格式无效')
return false
}
if (formMode.value === 'create' && !startTime.isAfter(dayjs())) {
ElMessage.error('开始时间必须晚于当前时间')
return false
}
if (!endTime.isAfter(startTime)) {
ElMessage.error('结束时间必须晚于开始时间')
return false
}
return true
}
const resetForm = () => {
form.id = 0
form.productId = undefined
form.flashPrice = 0.01
form.flashStock = 1
form.startTime = buildDefaultStartTime()
form.endTime = buildDefaultEndTime(form.startTime)
}
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
if (!validateTimeRange()) 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">
.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 p-5 shadow-sm;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
&__value { @apply text-3xl font-bold; }
&__label { @apply text-sm opacity-90 mt-2; }
}
.panel-card {
@apply bg-white rounded-xl p-5;
border: 1px solid #d8cebf;
box-shadow: 0 10px 24px rgba(23, 22, 20, 0.04);
}
.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 #d8cebf;
}
.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;
color: #171715;
}
.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));
}
.filter-card {
grid-template-columns: 1fr 1fr;
}
.detail-layout {
grid-template-columns: 1fr;
}
}
</style>