651 lines
16 KiB
Vue
651 lines
16 KiB
Vue
<template>
|
||
<div class="member-tier-container">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<div class="header-left">
|
||
<span class="title">会员等级配置</span>
|
||
<span class="subtitle">配置小程序会员页面展示的等级信息和权益图</span>
|
||
</div>
|
||
<el-button type="primary" @click="handleAdd">
|
||
<el-icon><Plus /></el-icon>
|
||
新增等级
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 卡片式展示 -->
|
||
<div class="tier-cards" v-loading="loading">
|
||
<div
|
||
v-for="tier in tierList"
|
||
:key="tier.id"
|
||
class="tier-card"
|
||
:class="{ disabled: tier.status !== 1 }"
|
||
>
|
||
<!-- 角标 -->
|
||
<div class="tier-badge" v-if="tier.badge">{{ tier.badge }}</div>
|
||
|
||
<!-- 状态标签 -->
|
||
<div class="tier-status">
|
||
<el-tag :type="tier.status === 1 ? 'success' : 'info'" size="small">
|
||
{{ tier.status === 1 ? '启用' : '禁用' }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- 等级信息 -->
|
||
<div class="tier-info">
|
||
<div class="tier-level">等级 {{ tier.level }}</div>
|
||
<div class="tier-name">{{ tier.name }}</div>
|
||
<div class="tier-price">
|
||
<span class="current-price">¥{{ tier.price }}</span>
|
||
<span class="original-price">¥{{ tier.originalPrice }}</span>
|
||
</div>
|
||
<div class="tier-discount" v-if="tier.discount">{{ tier.discount }}</div>
|
||
</div>
|
||
|
||
<!-- 权益图预览 -->
|
||
<div class="tier-image">
|
||
<el-image
|
||
v-if="tier.benefitsImage"
|
||
:src="getFullUrl(tier.benefitsImage)"
|
||
:preview-src-list="[getFullUrl(tier.benefitsImage)]"
|
||
fit="contain"
|
||
class="benefits-preview"
|
||
>
|
||
<template #error>
|
||
<div class="image-error">
|
||
<el-icon><Picture /></el-icon>
|
||
<span>加载失败</span>
|
||
</div>
|
||
</template>
|
||
</el-image>
|
||
<div v-else class="no-image">
|
||
<el-icon><Picture /></el-icon>
|
||
<span>暂无权益图</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="tier-actions">
|
||
<el-button type="primary" size="small" @click="handleEdit(tier)">
|
||
<el-icon><Edit /></el-icon>
|
||
编辑
|
||
</el-button>
|
||
<el-button type="danger" size="small" plain @click="handleDelete(tier)">
|
||
<el-icon><Delete /></el-icon>
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 排序标识 -->
|
||
<div class="tier-sort">排序: {{ tier.sort }}</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<el-empty v-if="!loading && tierList.length === 0" description="暂无会员等级配置">
|
||
<el-button type="primary" @click="handleAdd">立即添加</el-button>
|
||
</el-empty>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 编辑弹窗 -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="isEdit ? '编辑会员等级' : '新增会员等级'"
|
||
width="650px"
|
||
destroy-on-close
|
||
>
|
||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<el-form-item label="会员等级" prop="level">
|
||
<el-select v-model="formData.level" placeholder="请选择等级" style="width: 100%">
|
||
<el-option :value="1" label="等级1 - 永久会员" />
|
||
<el-option :value="2" label="等级2 - 诚意会员" />
|
||
<el-option :value="3" label="等级3 - 家庭版会员" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="等级名称" prop="name">
|
||
<el-input v-model="formData.name" placeholder="如:永久会员" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<el-form-item label="现价(元)" prop="price">
|
||
<el-input-number v-model="formData.price" :min="0" :precision="2" :step="0.01" :controls="false" style="width: 100%" placeholder="1299" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="原价(元)" prop="originalPrice">
|
||
<el-input-number v-model="formData.originalPrice" :min="0" :precision="2" :step="0.01" :controls="false" style="width: 100%" placeholder="1899" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<el-form-item label="角标文字" prop="badge">
|
||
<el-input v-model="formData.badge" placeholder="如:家庭版(可选)" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="折扣描述" prop="discount">
|
||
<el-input v-model="formData.discount" placeholder="如:8折优惠" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-form-item label="权益图片" prop="benefitsImage">
|
||
<div class="benefits-upload-wrapper">
|
||
<el-upload
|
||
class="image-uploader"
|
||
:action="uploadUrl"
|
||
:headers="uploadHeaders"
|
||
:show-file-list="false"
|
||
:on-success="handleUploadSuccess"
|
||
:before-upload="beforeUpload"
|
||
accept="image/*"
|
||
>
|
||
<div v-if="formData.benefitsImage" class="preview-wrapper">
|
||
<img :src="getFullUrl(formData.benefitsImage)" class="preview-image" />
|
||
<div class="preview-mask">
|
||
<el-icon><Upload /></el-icon>
|
||
<span>重新上传</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="upload-placeholder">
|
||
<el-icon><Plus /></el-icon>
|
||
<span>上传权益图</span>
|
||
</div>
|
||
</el-upload>
|
||
<div class="upload-tip">
|
||
<p><el-icon><InfoFilled /></el-icon> 建议尺寸:宽度750px,高度不限</p>
|
||
<p><el-icon><InfoFilled /></el-icon> 支持JPG、PNG格式,最大5MB</p>
|
||
<p><el-icon><InfoFilled /></el-icon> 此图片将在小程序会员页面展示</p>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<el-form-item label="排序" prop="sort">
|
||
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="状态" prop="status">
|
||
<el-switch
|
||
v-model="formData.status"
|
||
:active-value="1"
|
||
:inactive-value="2"
|
||
active-text="启用"
|
||
inactive-text="禁用"
|
||
inline-prompt
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||
import { Plus, Edit, Delete, Picture, Upload, InfoFilled } from '@element-plus/icons-vue'
|
||
import {
|
||
getMemberTierList,
|
||
createMemberTier,
|
||
updateMemberTier,
|
||
deleteMemberTier,
|
||
type MemberTier
|
||
} from '@/api/memberTier'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const userStore = useUserStore()
|
||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001/api'
|
||
const serverUrl = apiBaseUrl.replace(/\/api$/, '')
|
||
|
||
const loading = ref(false)
|
||
const tierList = ref<MemberTier[]>([])
|
||
const dialogVisible = ref(false)
|
||
const isEdit = ref(false)
|
||
const submitting = ref(false)
|
||
const formRef = ref<FormInstance>()
|
||
const editingId = ref<number | null>(null)
|
||
|
||
const formData = reactive({
|
||
level: 1,
|
||
name: '',
|
||
badge: '',
|
||
price: 0,
|
||
originalPrice: 0,
|
||
discount: '',
|
||
benefitsImage: '',
|
||
sort: 0,
|
||
status: 1
|
||
})
|
||
|
||
const formRules: FormRules = {
|
||
level: [{ required: true, message: '请选择会员等级', trigger: 'change' }],
|
||
name: [{ required: true, message: '请输入等级名称', trigger: 'blur' }],
|
||
price: [{ required: true, message: '请输入现价', trigger: 'blur' }],
|
||
originalPrice: [{ required: true, message: '请输入原价', trigger: 'blur' }]
|
||
}
|
||
|
||
const uploadUrl = computed(() => `${apiBaseUrl}/admin/upload`)
|
||
const uploadHeaders = computed(() => ({
|
||
Authorization: `Bearer ${userStore.token}`
|
||
}))
|
||
|
||
const getFullUrl = (url: string) => {
|
||
if (!url) return ''
|
||
if (url.startsWith('http')) return url
|
||
return `${serverUrl}${url}`
|
||
}
|
||
|
||
const loadList = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getMemberTierList()
|
||
tierList.value = res || []
|
||
} catch (error) {
|
||
console.error('加载列表失败:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const resetForm = () => {
|
||
formData.level = 1
|
||
formData.name = ''
|
||
formData.badge = ''
|
||
formData.price = 0
|
||
formData.originalPrice = 0
|
||
formData.discount = ''
|
||
formData.benefitsImage = ''
|
||
formData.sort = 0
|
||
formData.status = 1
|
||
editingId.value = null
|
||
}
|
||
|
||
const handleAdd = () => {
|
||
resetForm()
|
||
isEdit.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const handleEdit = (row: MemberTier) => {
|
||
isEdit.value = true
|
||
editingId.value = row.id
|
||
formData.level = row.level
|
||
formData.name = row.name
|
||
formData.badge = row.badge || ''
|
||
formData.price = row.price
|
||
formData.originalPrice = row.originalPrice
|
||
formData.discount = row.discount || ''
|
||
formData.benefitsImage = row.benefitsImage || ''
|
||
formData.sort = row.sort
|
||
formData.status = row.status
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const handleDelete = async (row: MemberTier) => {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定要删除「${row.name}」吗?删除后小程序将不再显示该等级。`,
|
||
'删除确认',
|
||
{ type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' }
|
||
)
|
||
await deleteMemberTier(row.id)
|
||
ElMessage.success('删除成功')
|
||
loadList()
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('删除失败:', error)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleUploadSuccess = (response: any) => {
|
||
if (response.code === 0 && response.data) {
|
||
formData.benefitsImage = response.data.url
|
||
ElMessage.success('上传成功')
|
||
} else {
|
||
ElMessage.error(response.message || '上传失败')
|
||
}
|
||
}
|
||
|
||
const beforeUpload = (file: File) => {
|
||
const isImage = file.type.startsWith('image/')
|
||
const isLt5M = file.size / 1024 / 1024 < 5
|
||
|
||
if (!isImage) {
|
||
ElMessage.error('只能上传图片文件!')
|
||
return false
|
||
}
|
||
if (!isLt5M) {
|
||
ElMessage.error('图片大小不能超过 5MB!')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formRef.value) return
|
||
|
||
try {
|
||
await formRef.value.validate()
|
||
submitting.value = true
|
||
|
||
const data = {
|
||
level: formData.level,
|
||
name: formData.name,
|
||
badge: formData.badge || undefined,
|
||
price: formData.price,
|
||
originalPrice: formData.originalPrice,
|
||
discount: formData.discount || undefined,
|
||
benefitsImage: formData.benefitsImage || undefined,
|
||
sort: formData.sort,
|
||
status: formData.status
|
||
}
|
||
|
||
if (isEdit.value && editingId.value) {
|
||
await updateMemberTier(editingId.value, data)
|
||
ElMessage.success('更新成功')
|
||
} else {
|
||
await createMemberTier(data)
|
||
ElMessage.success('创建成功')
|
||
}
|
||
|
||
dialogVisible.value = false
|
||
loadList()
|
||
} catch (error) {
|
||
console.error('提交失败:', error)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadList()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.member-tier-container {
|
||
padding: 20px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.header-left .title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.header-left .subtitle {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
/* 卡片式布局 */
|
||
.tier-cards {
|
||
display: flex;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.tier-card {
|
||
width: 280px;
|
||
background: linear-gradient(135deg, #fdfbf7 0%, #fff 100%);
|
||
border: 1px solid #e8e4dc;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
position: relative;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.tier-card:hover {
|
||
box-shadow: 0 4px 20px rgba(201, 168, 108, 0.15);
|
||
border-color: #c9a86c;
|
||
}
|
||
|
||
.tier-card.disabled {
|
||
opacity: 0.6;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
/* 角标 */
|
||
.tier-badge {
|
||
position: absolute;
|
||
top: -1px;
|
||
right: -1px;
|
||
padding: 4px 12px;
|
||
background: linear-gradient(135deg, #ff6b6b 0%, #ff5252 100%);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
border-radius: 0 12px 0 12px;
|
||
}
|
||
|
||
/* 状态标签 */
|
||
.tier-status {
|
||
position: absolute;
|
||
top: 12px;
|
||
left: 12px;
|
||
}
|
||
|
||
/* 等级信息 */
|
||
.tier-info {
|
||
text-align: center;
|
||
padding: 10px 0 15px;
|
||
border-bottom: 1px dashed #e8e4dc;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.tier-level {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tier-name {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #c9a86c;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tier-price {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.current-price {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.original-price {
|
||
font-size: 14px;
|
||
color: #c0c4cc;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.tier-discount {
|
||
font-size: 12px;
|
||
color: #ff6b6b;
|
||
background: #fff2f0;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
}
|
||
|
||
/* 权益图预览 */
|
||
.tier-image {
|
||
height: 120px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
margin-bottom: 15px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.benefits-preview {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.no-image,
|
||
.image-error {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #c0c4cc;
|
||
font-size: 12px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.no-image .el-icon,
|
||
.image-error .el-icon {
|
||
font-size: 32px;
|
||
}
|
||
|
||
/* 操作按钮 */
|
||
.tier-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 排序标识 */
|
||
.tier-sort {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 12px;
|
||
font-size: 11px;
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
/* 弹窗样式 */
|
||
.benefits-upload-wrapper {
|
||
display: flex;
|
||
gap: 20px;
|
||
}
|
||
|
||
.image-uploader :deep(.el-upload) {
|
||
border: 2px dashed #dcdfe6;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
transition: all 0.3s;
|
||
width: 200px;
|
||
height: 150px;
|
||
}
|
||
|
||
.image-uploader :deep(.el-upload:hover) {
|
||
border-color: #c9a86c;
|
||
}
|
||
|
||
.preview-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.preview-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.preview-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
gap: 8px;
|
||
}
|
||
|
||
.preview-wrapper:hover .preview-mask {
|
||
opacity: 1;
|
||
}
|
||
|
||
.preview-mask .el-icon {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.upload-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #909399;
|
||
gap: 8px;
|
||
}
|
||
|
||
.upload-placeholder .el-icon {
|
||
font-size: 32px;
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.upload-tip {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
color: #909399;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.upload-tip p {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.upload-tip .el-icon {
|
||
color: #c9a86c;
|
||
}
|
||
</style>
|