xiangyixiangqin/admin/src/views/content/memberTier.vue
2026-01-24 20:20:09 +08:00

651 lines
16 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="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>