585 lines
18 KiB
Vue
585 lines
18 KiB
Vue
<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>
|