feat(member): 优化等级权益管理与买家展示

- 优化客户权益配置、编辑表单及独立发放明细页面
- 完善客户等级图标、背景配置提示与 PC 权益展示
- 复用图片选择器并补齐素材元数据回传
This commit is contained in:
pikachu1995@126.com
2026-11-05 21:27:37 +08:00
parent 560286b7f6
commit 6be934d00b
14 changed files with 482 additions and 628 deletions

View File

@@ -0,0 +1,51 @@
<template>
<section class="benefits-panel">
<h3>会员权益</h3>
<div class="grade-options">
<button v-for="item in enabledGrades" :key="item.grade.id" :class="{active: selectedId === item.grade.id}" @click="selectedId = item.grade.id">
{{ item.grade.gradeName }}<small v-if="item.grade.id === currentGradeId">当前等级</small>
</button>
</div>
<p class="benefit-subtitle">{{ selected?.grade?.gradeName || '暂无等级' }} · 点击查看权益说明</p>
<el-empty v-if="!selected?.benefits?.length" description="暂无权益" />
<div v-else class="benefits-grid">
<button v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" :class="{locked: !owned(benefit)}" @click="detail = benefit">
<img v-if="benefit.benefitLogo" :src="benefit.benefitLogo" alt="" />
<strong>{{ benefit.benefitName }}</strong>
<span>{{ owned(benefit) ? '已享有' : '该等级专享' }}</span>
</button>
</div>
<el-dialog :model-value="!!detail" :title="detail?.benefitName || '权益说明'" width="520px" @close="detail = null">
<template v-if="detail">
<p>{{ owned(detail) ? '当前等级已享有该权益' : '此权益属于所选等级,当前尚未享有' }}</p>
<p v-if="detail.benefitType === 'POINT_MULTIPLIER'">购物积分倍率:{{ config(detail).multiplier || 1 }} 倍,同类权益取最高值。</p>
<p v-if="detail.benefitType === 'GIFT_POINT'">首次达到等级赠送 {{ config(detail).giftPoint || 0 }} 积分。</p>
<div class="benefit-description" v-html="detail.benefitDesc || '暂无说明'" />
</template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({gradeDetails:{type:Array,default:()=>[]},currentGradeId:{type:String,default:''}})
const selectedId = ref(''), detail = ref(null)
const enabledGrades = computed(()=>props.gradeDetails.filter(item=>item.grade?.gradeState === 'OPEN'))
const selected = computed(()=>enabledGrades.value.find(item=>item.grade.id === selectedId.value))
const current = computed(()=>enabledGrades.value.find(item=>item.grade.id === props.currentGradeId))
const owned = benefit => (current.value?.benefits || []).some(item=>item.id === benefit.id)
function config(benefit) { try { return JSON.parse(benefit.benefitConfig || '{}') } catch { return {} } }
watch([()=>props.currentGradeId,enabledGrades],()=>{
if(!selectedId.value || !enabledGrades.value.some(item=>item.grade.id===selectedId.value)) selectedId.value=props.currentGradeId || enabledGrades.value[0]?.grade.id || ''
},{immediate:true})
</script>
<style scoped>
.benefits-panel { margin:20px 0; padding:24px; background:#fff; border:1px solid #eee; border-radius:8px; }
h3 { margin:0 0 20px; font-size:18px; } .grade-options { display:flex; flex-wrap:wrap; gap:10px; }
.grade-options button { padding:10px 18px; border:1px solid #ddd; background:#fff; border-radius:6px; cursor:pointer; }
.grade-options button.active { background:#fff8eb; border-color:#c99a47; color:#866021; } small { margin-left:8px; }
.benefit-subtitle { color:#777; margin:20px 0; }.benefits-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:16px; }
.benefit-card { padding:20px 10px; display:flex; align-items:center; flex-direction:column; gap:12px; border:1px solid #f0e3cb; background:#fffcf6; border-radius:8px; cursor:pointer; }
.benefit-card img { width:48px; height:48px; object-fit:contain; }.benefit-card span { font-size:12px; color:#986d2a; }
.benefit-card.locked { background:#fafafa; border-color:#eee; color:#888; }.locked img { filter:grayscale(1); opacity:.5; }
.benefit-description { line-height:1.8; overflow-wrap:anywhere; }.benefit-description :deep(img) { max-width:100%; }
</style>

View File

@@ -25,6 +25,8 @@
</div>
</div>
<MemberBenefitPanel :grade-details="gradeDetails" :current-grade-id="String(currentGradeId)" />
<div class="grade-panel">
<div class="grade-tabs">
<div class="grade-tab" :class="{ active: activeGradeTab === 'rules' }" @click="activeGradeTab = 'rules'">获取经验值方式</div>
@@ -87,6 +89,7 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import MemberBenefitPanel from './MemberBenefitPanel.vue'
import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js'
defineOptions({ name: 'MemberGrade' })

View File

@@ -239,3 +239,6 @@ export const deleteMemberBenefit = (id) => {
export const getMemberBenefitTypes = () => {
return getRequest("/member/benefit/types");
};
export const getMemberBenefitDeliveries = params => getRequest('/member/benefit/deliveries', params);
export const retryMemberBenefitDelivery = id => postRequest('/member/benefit/deliveries/' + id + '/retry');

View File

@@ -3,7 +3,7 @@
<!-- 使用 fullscreen 类来控制是否全屏显示 -->
<div :class="{ fullscreen: fullscreen }" class="tinymce-container">
<!-- 使用 tinymce-textarea 类作为编辑器的文本区域 -->
<uploadImage @callback="insertImage" />
<uploadImage :use-image-picker="imagePicker" @callback="insertImage" />
<textarea :id="tinymceId" class="tinymce-textarea" />
</div>
</div>
@@ -21,10 +21,15 @@ import {
} from "vue";
import { initEditor } from "@/components/editor/config";
import uploadImage from "@/components/editor/upload-image.vue";
import { useFormItem } from "element-plus";
defineOptions({ name: "Tinymce" });
const props = defineProps({
imagePicker: {
type: Boolean,
default: false,
},
modelValue: {
type: String,
default: "",
@@ -39,6 +44,7 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:modelValue", "input"]);
const { formItem } = useFormItem();
// 引入编辑器的配置(data 中的 initEditor 与 import 同名,直接使用 import)
const hasChange = ref(false); // 标记内容是否有更改
@@ -66,6 +72,9 @@ watch(editorContent, (val) => {
function emitContent(content) {
emit("update:modelValue", content);
emit("input", content);
if (props.imagePicker && content) {
nextTick(() => formItem?.validate("change").catch(() => {}));
}
}
// 数据返回并给富文本框插入图片
function insertImage(arr) {

View File

@@ -57,6 +57,7 @@
<el-dialog
v-model="showOssManager"
title="选择图片"
width="1000px"
append-to-body
:z-index="3600"
@@ -94,6 +95,7 @@ import { Message } from "@/utils/message";
import OssManage from "@/views/sys/oss-manage/ossManage.vue";
defineOptions({ name: "upload-image" });
const props = defineProps({ useImagePicker: { type: Boolean, default: false } });
const emit = defineEmits(["callback"]);
@@ -120,6 +122,10 @@ function draggableItemKey(item) {
return item?.url || item;
}
function handleClickUploadImage() {
if (props.useImagePicker) {
importOSS();
return;
}
show.value = true;
}
function handleView(url) {
@@ -158,7 +164,16 @@ function handleUploadError(err) {
Message.error(err?.message || String(err));
}
function confirmUrls() {
applySelectedImages();
if (props.useImagePicker) {
const urls = [...new Set(selectedImage.value.map(parseOssSelectionUrl).filter(Boolean))];
if (!urls.length) {
Message.warning("请选择图片");
return;
}
emit("callback", urls);
} else {
applySelectedImages();
}
showOssManager.value = false;
}
function cancelOssImport() {

View File

@@ -105,6 +105,7 @@ const props = defineProps({
default: false,
},
maxlength: Number,
beforeSelect: Function,
});
const emit = defineEmits(["update:modelValue", "input", "on-change"]);
const attrs = useAttrs();
@@ -131,6 +132,7 @@ function handleCLickImg(val, index) {
});
}
function handleOssCallback(item) {
if (item?.url && props.beforeSelect && props.beforeSelect(item) === false) return;
picModalFlag.value = false;
if (!item?.url) return;
currentValue.value = item.url;

View File

@@ -224,6 +224,12 @@ export const otherRouter = {
name: "member-benefit",
component: () => import("@/views/member/benefit/index.vue")
},
{
path: "member-benefit-deliveries",
title: "权益发放明细",
name: "member-benefit-deliveries",
component: () => import("@/views/member/benefit/BenefitDeliveryRecords.vue")
},
{
path: "member-grade-experience",
title: "等级权益设置",
@@ -236,7 +242,7 @@ export const otherRouter = {
name: "member-grade-experience-log",
component: () => import("@/views/member/grade/experience-log.vue")
},
{
path: "goods/goods-info/goodsDetail",

View File

@@ -0,0 +1,40 @@
<template>
<el-form-item v-if="form.benefitType === 'POINT_MULTIPLIER'" label="积分倍率" required>
<div class="number-field"><el-input-number v-model="form.multiplier" :min="1" :max="100" :precision="2" controls-position="right" /><span>倍</span></div>
<div class="hint">购物积分基数 × 倍率,同类权益取最高倍率。</div>
</el-form-item>
<div v-if="['CUSTOM', 'GRADE_BADGE', 'EXCLUSIVE_CUSTOMER_SERVICE'].includes(form.benefitType)" class="rule-description">{{ typeDescription }}</div>
<template v-if="form.benefitType === 'COUPON_PACKAGE'">
<el-form-item label="发券规则" required>
<el-radio-group v-model="form.grantRule">
<el-radio value="ONCE">发放一次</el-radio>
<el-radio value="MONTHLY">每月发放</el-radio>
<el-radio value="PERIOD">按周期发放</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.grantRule === 'MONTHLY'" label="发放日期" required>
<div class="number-field"><el-input-number v-model="form.grantDay" :min="1" :max="31" :precision="0" controls-position="right" /><span>号</span></div>
<div class="hint">当月不存在指定日期时,取当月最后一天。</div>
</el-form-item>
<el-form-item v-if="form.grantRule === 'PERIOD'" label="发放周期" required>
<div class="number-field"><el-input-number v-model="form.periodMonths" :min="1" :max="12" :precision="0" controls-position="right" /><span>个月</span></div>
<div class="hint">以首次达到等级的日期为起点,首期立即发放。</div>
</el-form-item>
<div class="rule-description coupon-description">最多选择 10 张平台优惠券,每券赠送 1~10 张。仅发放当前周期,暂停期间未生成的历史周期不追发。</div>
</template>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({ form: { type: Object, required: true } });
const typeDescription = computed(() => ({
GRADE_BADGE: '展示会员的等级徽章。请在下方配置权益图标和介绍,并在客户等级中关联使用。',
EXCLUSIVE_CUSTOMER_SERVICE: '展示会员专属客服权益。会员可通过商城现有客服入口联系,具体服务内容请在权益介绍中说明。',
CUSTOM: '展示自定义会员权益。请在权益介绍中说明适用条件、参与方式及服务内容。',
}[props.form.benefitType] || ''));
</script>
<style scoped>
.number-field { display: flex; align-items: center; gap: 10px; }
.hint { width: 100%; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.rule-description { padding: 12px 16px; color: var(--el-text-color-regular); background: #f6f8fb; border-radius: 6px; font-size: 13px; line-height: 22px; }
.coupon-description { margin-bottom: 20px; }
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="search benefit-delivery-page">
<el-card shadow="never">
<el-form :model="query" inline class="search-form" label-width="70px" @submit.prevent @keyup.enter="search">
<el-form-item label="会员ID"><el-input v-model="query.memberId" placeholder="请输入会员ID" clearable style="width:240px" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:240px">
<el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="待发放" value="PENDING" />
</el-select></el-form-item>
<el-form-item><el-button type="primary" class="search-btn" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt_10">
<template #header><div class="records-header"><span>权益发放明细</span><el-button @click="router.push({ name: 'member-benefit' })">返回客户权益</el-button></div></template>
<el-table :data="rows" v-loading="loading" border>
<el-table-column prop="memberId" label="会员ID" min-width="160" show-overflow-tooltip />
<el-table-column prop="benefitName" label="权益" min-width="130" />
<el-table-column prop="sourceRef" label="来源等级ID" min-width="150" show-overflow-tooltip />
<el-table-column prop="cycleKey" label="发放周期" min-width="130" />
<el-table-column label="状态" width="100"><template #default="{row}">{{ statuses[row.status] || row.status }}</template></el-table-column>
<el-table-column prop="attempts" label="尝试次数" width="90" />
<el-table-column prop="errorMessage" label="失败原因" min-width="200" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="90"><template #default="{row}"><el-button v-if="row.status === 'FAILED'" link type="primary" :loading="retrying === row.id" @click="retry(row)">重试</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.pageNumber" :total="total" :page-size="query.pageSize" layout="total, prev, pager, next" @current-change="load" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getMemberBenefitDeliveries, retryMemberBenefitDelivery } from '@/api/member';
defineOptions({ name: 'memberBenefitDeliveries' });
const router = useRouter();
const loading = ref(false), rows = ref([]), total = ref(0), retrying = ref('');
const query = reactive({memberId:'',status:'',pageNumber:1,pageSize:20});
const statuses = {SUCCESS:'成功',FAILED:'失败',PENDING:'待发放'};
async function load() {
loading.value = true;
try { const res = await getMemberBenefitDeliveries({ ...query }); if(res.success) { rows.value = res.result.records || []; total.value = res.result.total || 0; } }
catch { ElMessage.error('发放明细加载失败,请重新搜索'); }
finally { loading.value = false; }
}
function search() { query.pageNumber = 1; load(); }
function reset() { query.memberId = ''; query.status = ''; search(); }
async function retry(row) {
try { await ElMessageBox.confirm('按原发放配置补发此权益,系统会检查会员当前资格。', '重试权益发放'); }
catch { return; }
retrying.value = row.id;
try { const res = await retryMemberBenefitDelivery(row.id); if(res.success) ElMessage.success('补发成功'); await load(); }
catch { ElMessage.error('补发失败,请稍后重试'); }
finally { retrying.value = ''; }
}
onMounted(load);
</script>
<style scoped>
.records-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.el-pagination { margin-top: 20px; justify-content: flex-end; }
</style>

View File

@@ -0,0 +1,129 @@
<template>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>基本信息</h3></div>
<div class="benefit-form-grid">
<el-form-item label="权益名称" prop="benefitName">
<el-input v-model="form.benefitName" maxlength="50" show-word-limit placeholder="请输入权益名称" />
</el-form-item>
<el-form-item label="权益类型" prop="benefitType">
<el-select v-model="form.benefitType" :disabled="editing" :clearable="!editing" placeholder="请选择权益类型" @change="$emit('type-change', $event)">
<el-option v-for="item in typeOptions" :key="item.value" :value="item.value" :label="item.description" />
</el-select>
<div v-if="editing" class="benefit-field-hint">权益创建后不支持修改类型</div>
</el-form-item>
</div>
</section>
<section v-if="form.benefitType" class="benefit-section">
<div class="benefit-section-heading"><h3>权益规则</h3><span>{{ typeLabel }}</span></div>
<el-form-item v-if="form.benefitType === 'GIFT_POINT'" label="赠送积分" prop="giftPoint" required>
<div class="benefit-number-field">
<el-input-number v-model="form.giftPoint" :min="0" :max="99999" :precision="0" controls-position="right" placeholder="请输入积分数量" />
<span class="benefit-input-unit">积分</span>
</div>
<div class="benefit-field-hint">首次达到关联等级时赠送,范围为 0~99999 积分。</div>
</el-form-item>
<BenefitConfigFields :form="form" />
<el-form-item v-if="form.benefitType === 'COUPON_PACKAGE'" label="赠送优惠券" prop="couponPackageRows" required class="benefit-coupon-form-item">
<div class="benefit-coupon-field">
<div class="benefit-coupon-toolbar">
<span>已选择 <strong>{{ form.couponPackageRows.length }}</strong> / 10 张优惠券</span>
<el-button type="primary" plain @click="$emit('select-coupons', which)">添加优惠券</el-button>
</div>
<el-table border size="small" class="benefit-coupon-table" :data="form.couponPackageRows" empty-text="暂未选择优惠券,请点击右上方添加">
<el-table-column prop="couponName" label="优惠券名称" min-width="160" show-overflow-tooltip />
<el-table-column label="有效期" min-width="200"><template #default="{ row }"><span v-if="row" v-html="row.validRange || '-'" /></template></el-table-column>
<el-table-column prop="faceValueLabel" label="面额" min-width="95" />
<el-table-column label="赠送张数" width="135"><template #default="{ row, $index }">
<el-input-number v-if="row" :model-value="row.quantity" :min="1" :max="10" :precision="0" controls-position="right" style="width: 104px" @change="value => $emit('quantity-change', which, $index, value)" />
</template></el-table-column>
<el-table-column label="操作" width="70" align="center"><template #default="{ row, $index }">
<el-button v-if="row" link type="danger" @click="$emit('remove-coupon', which, $index)">删除</el-button>
</template></el-table-column>
</el-table>
</div>
</el-form-item>
</section>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>展示内容</h3><span>用于会员端权益展示</span></div>
<el-form-item label="权益图标" prop="benefitLogo">
<div class="benefit-logo-field">
<div class="benefit-logo-preview">
<el-image v-if="form.benefitLogo" :src="form.benefitLogo" fit="contain" :preview-src-list="[form.benefitLogo]" preview-teleported>
<template #error><span class="benefit-logo-placeholder">图片不可用</span></template>
</el-image>
<span v-else class="benefit-logo-placeholder">图标预览</span>
</div>
<BenefitLogoInput v-model="form.benefitLogo" class="benefit-logo-upload" />
</div>
</el-form-item>
<el-form-item label="权益介绍" prop="benefitDesc">
<RichEditor v-model="form.benefitDesc" image-picker height="300px" class="benefit-rich-editor" />
</el-form-item>
</section>
<section class="benefit-section">
<div class="benefit-section-heading"><h3>发布设置</h3></div>
<div class="benefit-form-grid">
<el-form-item label="启用状态" prop="benefitState">
<el-radio-group v-model="form.benefitState"><el-radio value="OPEN">开启</el-radio><el-radio value="CLOSE">关闭</el-radio></el-radio-group>
<div class="benefit-field-hint">关闭后,会员端隐藏该权益并停止后续发放。</div>
</el-form-item>
<el-form-item label="显示排序" prop="benefitSort">
<el-input-number v-model="form.benefitSort" :min="1" :max="9999" :precision="0" controls-position="right" />
<div class="benefit-field-hint">数字越小越靠前,范围为 1~9999。</div>
</el-form-item>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue';
import BenefitConfigFields from './BenefitConfigFields.vue';
import BenefitLogoInput from './BenefitLogoInput.vue';
import RichEditor from '@/components/editor/index.vue';
const props = defineProps({
form: { type: Object, required: true },
typeOptions: { type: Array, default: () => [] },
editing: { type: Boolean, default: false },
which: { type: String, required: true },
});
defineEmits(['type-change', 'select-coupons', 'quantity-change', 'remove-coupon']);
const typeLabel = computed(() => props.typeOptions.find(item => item.value === props.form.benefitType)?.description || '');
</script>
<style lang="scss" scoped>
.benefit-section { padding: 24px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; background: #fff; }
.benefit-section + .benefit-section { margin-top: 16px; }
.benefit-section-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 22px; }
.benefit-section-heading h3 { margin: 0; color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; line-height: 22px; }
.benefit-section-heading span { color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.benefit-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.benefit-field-hint { width: 100%; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.benefit-section :deep(.el-form-item) { margin-bottom: 22px; }
.benefit-section > :deep(.el-form-item:last-child), .benefit-form-grid > :deep(.el-form-item) { margin-bottom: 0; }
.benefit-section :deep(.el-form-item__label) { padding: 0; margin-bottom: 8px; font-weight: 500; line-height: 22px; }
.benefit-section :deep(.el-form-item__content) { min-width: 0; }
.benefit-section :deep(.el-select) { width: 100%; }
.benefit-section :deep(.el-input-number) { width: 180px; max-width: 100%; }
.benefit-number-field { display: flex; gap: 10px; align-items: center; }
.benefit-input-unit { color: var(--el-text-color-regular); }
.benefit-coupon-field, .benefit-rich-editor { width: 100%; min-width: 0; }
.benefit-coupon-toolbar { display: flex; gap: 12px; flex-wrap: wrap; justify-content: space-between; align-items: center; margin-bottom: 12px; color: var(--el-text-color-secondary); font-size: 12px; }
.benefit-coupon-toolbar strong { color: var(--el-color-primary); font-weight: 500; }
.benefit-coupon-table { border-radius: 6px; overflow: hidden; }
.benefit-logo-field { display: flex; align-items: center; gap: 20px; width: 100%; }
.benefit-logo-preview { width: 88px; height: 88px; flex: 0 0 88px; display: flex; align-items: center; justify-content: center; border: 1px dashed var(--el-border-color); border-radius: 8px; background-color: #fafbfc; overflow: hidden; }
.benefit-logo-preview :deep(.el-image) { width: 64px; height: 64px; }
.benefit-logo-placeholder { color: var(--el-text-color-placeholder); font-size: 12px; }
.benefit-logo-upload { flex: 1; min-width: 0; }
@media (max-width: 640px) {
.benefit-section { padding: 18px 16px; }
.benefit-form-grid { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.benefit-logo-field { flex-direction: column; align-items: flex-start; gap: 12px; }
.benefit-logo-upload { width: 100%; }
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="logo-input">
<UploadPicInput :model-value="modelValue" :before-select="validateSelection" placeholder="请选择图标或粘贴图片地址" @update:model-value="updateLogo" />
<span class="hint">透明PNG,建议100×100像素,上传大小不超过10KB。</span>
</div>
</template>
<script setup>
import UploadPicInput from '@/components/lili/upload-pic-input.vue';
import { nextTick } from 'vue';
import { ElMessage, useFormItem } from 'element-plus';
defineProps({modelValue:{type:String,default:''}});
const emit = defineEmits(['update:modelValue']);
const { formItem } = useFormItem();
function validateSelection({ file }) {
if (!file || file.fileType !== 'image/png' || Number(file.fileSize) > 10240) {
ElMessage.error('请选择不超过10KB的PNG图标');
return false;
}
return true;
}
async function updateLogo(value) {
emit('update:modelValue', value);
await nextTick();
formItem?.validate('change').catch(() => {});
}
</script>
<style scoped>
.logo-input { width: 100%; min-width: 0; }
.hint { display: block; margin-top: 8px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
</style>

View File

@@ -3,10 +3,38 @@
<div class="search member-benefit">
<el-card>
<el-form
:model="searchForm"
inline
label-width="70px"
class="search-form"
@submit.prevent
@keyup.enter="searchBenefits"
>
<el-form-item label="权益名称" prop="keyword">
<el-input
v-model="searchForm.keyword"
placeholder="请输入权益名称"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="状态" prop="state">
<el-select v-model="searchForm.state" placeholder="全部状态" clearable style="width: 240px">
<el-option label="开启" value="OPEN" />
<el-option label="关闭" value="CLOSE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="searchBenefits">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="operation padding-row benefit-actions">
<el-card>
<div class="operation padding-row">
<el-button type="primary" @click="openAdd">添加权益设置</el-button>
<el-button @click="getData">刷新</el-button>
<el-button type="primary" @click="router.push({ name: 'member-benefit-deliveries' })">发放明细</el-button>
</div>
@@ -31,7 +59,7 @@
<!-- 权益ID -->
<el-table-column prop="id" label="权益ID" min-width="150" show-overflow-tooltip />
<el-table-column prop="id" label="权益ID" width="100" align="center" show-overflow-tooltip />
@@ -83,7 +111,7 @@
<!-- 权益编码 -->
<el-table-column prop="benefitCode" label="权益编码" min-width="140" show-overflow-tooltip />
@@ -161,32 +189,6 @@
</div>
<div class="pagination-wrap mt_10">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:total="total"
:page-sizes="[20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</el-card>
@@ -240,483 +242,53 @@
<el-drawer
v-model="addFlag"
title="添加权益设置"
size="1120px"
direction="rtl"
:z-index="950"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="benefit-form-drawer"
v-model="addFlag" title="添加权益设置" size="1040px" direction="rtl"
:z-index="950" :close-on-click-modal="false" append-to-body destroy-on-close class="benefit-form-drawer"
>
<el-form ref="addForm" :model="formAdd" :rules="rulesAdd" label-width="110px">
<el-form-item label="权益类型" prop="benefitType">
<el-select
v-model="formAdd.benefitType"
clearable
placeholder="请选择权益类型"
style="width: 100%"
@change="onAddBenefitTypeChange"
>
<el-option
v-for="item in benefitTypeOptions"
:key="item.value"
:value="item.value"
:label="item.description"
/>
</el-select>
</el-form-item>
<el-form-item
v-show="formAdd.benefitType === 'GIFT_POINT'"
label="赠送积分"
prop="giftPoint"
:required="formAdd.benefitType === 'GIFT_POINT'"
>
<el-input-number
v-model="formAdd.giftPoint"
:min="0"
:max="99999"
:precision="0"
style="width: 220px"
placeholder="必填,范围 0-99999"
/>
</el-form-item>
<el-form-item
v-show="formAdd.benefitType === 'COUPON_PACKAGE'"
class="benefit-coupon-form-item"
label="赠送优惠券"
prop="couponPackageRows"
:required="formAdd.benefitType === 'COUPON_PACKAGE'"
>
<div class="benefit-coupon-field">
<el-button plain class="mb_10" @click="openCouponPicker('add')">添加优惠券</el-button>
<el-table
border
size="small"
class="benefit-coupon-table"
:data="formAdd.couponPackageRows"
empty-text="请添加优惠券"
>
<el-table-column prop="couponName" label="优惠券名称" min-width="180" show-overflow-tooltip />
<el-table-column label="有效期" min-width="280">
<template #default="{ row }">
<span v-if="row" v-html="row.validRange || '-'" />
</template>
</el-table-column>
<el-table-column prop="faceValueLabel" label="面额" min-width="100" />
<el-table-column label="赠送张数" width="150">
<template #default="{ row, $index }">
<el-input-number
v-if="row"
:model-value="row.quantity"
:min="1"
:max="10"
:precision="0"
style="width: 100px"
@change="(val) => onCouponQuantityChange('add', $index, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row, $index }">
<a v-if="row" class="link-text" @click="removeCouponRow('add', $index)">删除</a>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="权益名称" prop="benefitName">
<el-input v-model="formAdd.benefitName" maxlength="50" placeholder="请输入权益名称" />
</el-form-item>
<el-form-item label="权益LOGO" prop="benefitLogo">
<div class="benefit-logo-field">
<div v-if="formAdd.benefitLogo" class="benefit-logo-thumb">
<img :src="formAdd.benefitLogo" alt="" />
</div>
<div class="benefit-logo-upload">
<upload-pic-input v-model="formAdd.benefitLogo" placeholder="请上传权益 LOGO 图片地址" style="width: 100%" />
</div>
</div>
</el-form-item>
<el-form-item label="权益介绍" prop="benefitDesc">
<el-input v-model="formAdd.benefitDesc" type="textarea" :rows="4" maxlength="500" placeholder="请输入权益介绍" />
</el-form-item>
<el-form-item label="启用状态" prop="benefitState">
<el-radio-group v-model="formAdd.benefitState">
<el-radio value="OPEN">开启</el-radio>
<el-radio value="CLOSE">关闭</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="benefitSort">
<el-input-number v-model="formAdd.benefitSort" :min="1" :max="9999" :precision="0" style="width: 220px" />
</el-form-item>
</el-form>
<template #footer>
<div class="benefit-drawer-footer-btns">
<el-button @click="addFlag = false">取消</el-button>
<el-button type="primary" :loading="submitAddLoading" @click="submitAdd">提交</el-button>
<template #header="{ titleId, titleClass }">
<div class="benefit-drawer-heading">
<h2 :id="titleId" :class="titleClass">添加权益设置</h2>
<p>配置权益规则、展示内容及发布状态</p>
</div>
</template>
<el-form ref="addForm" :model="formAdd" :rules="rulesAdd" label-position="top" class="benefit-editor-form" @submit.prevent>
<BenefitFormFields
:form="formAdd" :type-options="benefitTypeOptions" which="add"
@type-change="onAddBenefitTypeChange" @select-coupons="openCouponPicker"
@quantity-change="onCouponQuantityChange" @remove-coupon="removeCouponRow"
/>
</el-form>
<template #footer>
<div class="benefit-drawer-footer-btns">
<el-button @click="addFlag = false">取消</el-button>
<el-button type="primary" :loading="submitAddLoading" @click="submitAdd">保存权益</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-model="editFlag"
title="编辑权益设置"
size="1120px"
direction="rtl"
:z-index="950"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="benefit-form-drawer"
v-model="editFlag" title="编辑权益设置" size="1040px" direction="rtl"
:z-index="950" :close-on-click-modal="false" append-to-body destroy-on-close class="benefit-form-drawer"
>
<el-form ref="editForm" :model="formEdit" :rules="rulesEdit" label-width="110px">
<el-input v-model="formEdit.id" v-show="false" />
<el-form-item label="权益类型" prop="benefitType">
<el-select
v-model="formEdit.benefitType"
clearable
placeholder="请选择权益类型"
style="width: 100%"
@change="onEditBenefitTypeChange"
>
<el-option
v-for="item in benefitTypeOptions"
:key="item.value"
:value="item.value"
:label="item.description"
/>
</el-select>
</el-form-item>
<el-form-item
v-show="formEdit.benefitType === 'GIFT_POINT'"
label="赠送积分"
prop="giftPoint"
:required="formEdit.benefitType === 'GIFT_POINT'"
>
<el-input-number
v-model="formEdit.giftPoint"
:min="0"
:max="99999"
:precision="0"
style="width: 220px"
placeholder="必填,范围 0-99999"
/>
</el-form-item>
<el-form-item
v-show="formEdit.benefitType === 'COUPON_PACKAGE'"
class="benefit-coupon-form-item"
label="赠送优惠券"
prop="couponPackageRows"
:required="formEdit.benefitType === 'COUPON_PACKAGE'"
>
<div class="benefit-coupon-field">
<el-button plain class="mb_10" @click="openCouponPicker('edit')">添加优惠券</el-button>
<el-table
border
size="small"
class="benefit-coupon-table"
:data="formEdit.couponPackageRows"
empty-text="请添加优惠券"
>
<el-table-column prop="couponName" label="优惠券名称" min-width="180" show-overflow-tooltip />
<el-table-column label="有效期" min-width="280">
<template #default="{ row }">
<span v-if="row" v-html="row.validRange || '-'" />
</template>
</el-table-column>
<el-table-column prop="faceValueLabel" label="面额" min-width="100" />
<el-table-column label="赠送张数" width="150">
<template #default="{ row, $index }">
<el-input-number
v-if="row"
:model-value="row.quantity"
:min="1"
:max="10"
:precision="0"
style="width: 100px"
@change="(val) => onCouponQuantityChange('edit', $index, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row, $index }">
<a v-if="row" class="link-text" @click="removeCouponRow('edit', $index)">删除</a>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="权益名称" prop="benefitName">
<el-input v-model="formEdit.benefitName" maxlength="50" placeholder="请输入权益名称" />
</el-form-item>
<el-form-item label="权益LOGO" prop="benefitLogo">
<div class="benefit-logo-field">
<div v-if="formEdit.benefitLogo" class="benefit-logo-thumb">
<img :src="formEdit.benefitLogo" alt="" />
</div>
<div class="benefit-logo-upload">
<upload-pic-input v-model="formEdit.benefitLogo" placeholder="请上传权益 LOGO 图片地址" style="width: 100%" />
</div>
</div>
</el-form-item>
<el-form-item label="权益介绍" prop="benefitDesc">
<el-input v-model="formEdit.benefitDesc" type="textarea" :rows="4" maxlength="500" placeholder="请输入权益介绍" />
</el-form-item>
<el-form-item label="启用状态" prop="benefitState">
<el-radio-group v-model="formEdit.benefitState">
<el-radio value="OPEN">开启</el-radio>
<el-radio value="CLOSE">关闭</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="benefitSort">
<el-input-number v-model="formEdit.benefitSort" :min="1" :max="9999" :precision="0" style="width: 220px" />
</el-form-item>
</el-form>
<template #footer>
<div class="benefit-drawer-footer-btns">
<el-button @click="editFlag = false">取消</el-button>
<el-button type="primary" :loading="submitEditLoading" @click="submitEdit">提交</el-button>
<template #header="{ titleId, titleClass }">
<div class="benefit-drawer-heading">
<h2 :id="titleId" :class="titleClass">编辑权益设置</h2>
<p>配置权益规则、展示内容及发布状态</p>
</div>
</template>
<el-form ref="editForm" :model="formEdit" :rules="rulesEdit" label-position="top" class="benefit-editor-form" @submit.prevent>
<BenefitFormFields
:form="formEdit" :type-options="benefitTypeOptions" which="edit" editing
@type-change="onEditBenefitTypeChange" @select-coupons="openCouponPicker"
@quantity-change="onCouponQuantityChange" @remove-coupon="removeCouponRow"
/>
</el-form>
<template #footer>
<div class="benefit-drawer-footer-btns">
<el-button @click="editFlag = false">取消</el-button>
<el-button type="primary" :loading="submitEditLoading" @click="submitEdit">保存修改</el-button>
</div>
</template>
</el-drawer>
</div>
@@ -737,8 +309,8 @@ import { formatPromotionCouponValidityHtml } from "@/utils/promotions";
import couponTemplate from "@/views/promotions/coupon/coupon.vue";
import uploadPicInput from "@/components/lili/upload-pic-input.vue";
import BenefitFormFields from './BenefitFormFields.vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from "element-plus";
@@ -747,6 +319,8 @@ defineOptions({ name: "memberBenefit" });
const router = useRouter();
const GIFT_POINT = "GIFT_POINT";
const COUPON_PACKAGE = "COUPON_PACKAGE";
@@ -774,6 +348,10 @@ const buildDefaultForm = () => ({
benefitConfig: "",
giftPoint: null,
multiplier: 1,
grantRule: "ONCE",
grantDay: 1,
periodMonths: 1,
couponPackageRows: [],
@@ -799,13 +377,11 @@ const benefitTypeOptions = ref([]);
const loading = ref(false);
const total = ref(0);
const searchForm = ref({
pageNumber: 1,
keyword: "",
pageSize: 20,
state: "",
});
@@ -903,6 +479,7 @@ function buildFormRules(which) {
benefitType: [{ required: true, message: "请选择权益类型", trigger: "change" }],
benefitDesc: [{ required: true, message: "请输入权益介绍", trigger: "change" }],
benefitName: [{ required: true, message: "请输入权益名称", trigger: "blur" }],
benefitLogo: [{ required: true, message: "请配置权益LOGO", trigger: "change" }],
@@ -979,6 +556,8 @@ function validateCouponPackage(form, callback) {
const rows = form.couponPackageRows || [];
if (rows.length > 10) return callback(new Error("最多选择10张优惠券"));
if (!rows.length) {
return callback(new Error("请添加至少一张优惠券"));
@@ -989,7 +568,7 @@ function validateCouponPackage(form, callback) {
const q = Number(rows[i].quantity);
if (Number.isNaN(q) || q < 1 || q > 10) {
if (!Number.isInteger(q) || q < 1 || q > 10) {
return callback(new Error("每张券赠送张数需在 1~10 之间"));
@@ -1047,32 +626,14 @@ function buildCouponDisplayRow(detail, quantity) {
}
function buildBenefitConfigString(slice) {
const { benefitType, giftPoint, couponPackageRows } = slice;
if (benefitType === GIFT_POINT) {
return JSON.stringify({ giftPoint: Number(giftPoint) });
}
if (benefitType === COUPON_PACKAGE) {
const coupons = (couponPackageRows || []).map((r) => ({
couponId: String(r.couponId),
quantity: Math.min(10, Math.max(1, Number(r.quantity) || 1)),
}));
return JSON.stringify({ coupons });
}
return "";
function buildBenefitConfigString(form) {
if(form.benefitType === GIFT_POINT) return JSON.stringify({giftPoint:Number(form.giftPoint)});
if(form.benefitType === 'POINT_MULTIPLIER') return JSON.stringify({multiplier:Number(form.multiplier)});
if(form.benefitType === COUPON_PACKAGE) return JSON.stringify({
grantRule:form.grantRule, grantDay:form.grantDay, periodMonths:form.periodMonths,
coupons:(form.couponPackageRows || []).map(r=>({couponId:String(r.couponId),quantity:Number(r.quantity)}))
});
return '{}';
}
function parseGiftPointFromConfig(benefitType, benefitConfigStr) {
@@ -1317,6 +878,11 @@ function confirmCouponPicker() {
const list = couponPicker.value?.getSelection?.() || [];
if (list.length > 10) {
ElMessage.warning("最多选择10张优惠券");
return;
}
if (!list.length) {
ElMessage.warning("请至少选择一张优惠券");
@@ -1395,29 +961,11 @@ function loadBenefitTypes() {
}
function changePage(page) {
searchForm.value.pageNumber = page;
getData();
}
function changePageSize(size) {
searchForm.value.pageSize = size;
searchForm.value.pageNumber = 1;
getData();
}
function getData() {
loading.value = true;
const params = { ...searchForm.value };
const params = { ...searchForm.value, pageNumber: 1, pageSize: 999 };
API_Member.getMemberBenefitByPage(params).then((res) => {
@@ -1427,20 +975,18 @@ function getData() {
data.value = Array.isArray(res.result.records) ? res.result.records : [];
total.value = Number(res.result.total) || 0;
} else {
data.value = [];
total.value = 0;
}
});
}
function searchBenefits() { getData(); }
function openAdd() {
addFlag.value = true;
@@ -1479,15 +1025,7 @@ function submitAdd() {
...rest,
benefitConfig: buildBenefitConfigString({
benefitType: formAdd.value.benefitType,
giftPoint,
couponPackageRows,
}),
benefitConfig: buildBenefitConfigString(formAdd.value),
};
@@ -1549,6 +1087,8 @@ function openEdit(row) {
const bt = detail.benefitType || "";
let extension = {};
try { extension = JSON.parse(detail.benefitConfig || '{}'); } catch { /* 旧数据按默认配置回显 */ }
const parsed = parseGiftPointFromConfig(bt, detail.benefitConfig || "");
const couponSnippets = parseCouponsFromConfig(detail.benefitConfig || "");
@@ -1574,6 +1114,10 @@ function openEdit(row) {
benefitCode: detail.benefitCode || "",
benefitType: bt,
multiplier: extension.multiplier ?? 1,
grantRule: extension.grantRule || "ONCE",
grantDay: extension.grantDay || 1,
periodMonths: extension.periodMonths || 1,
benefitLogo: detail.benefitLogo || "",
@@ -1627,15 +1171,7 @@ function submitEdit() {
...rest,
benefitConfig: buildBenefitConfigString({
benefitType: formEdit.value.benefitType,
giftPoint,
couponPackageRows,
}),
benefitConfig: buildBenefitConfigString(formEdit.value),
};
@@ -1755,16 +1291,6 @@ onMounted(() => {
<style lang="scss" scoped>
.benefit-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.benefit-actions .el-button + .el-button {
margin-left: 0;
}
.font-mono {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
@@ -1853,18 +1379,6 @@ onMounted(() => {
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
}
.benefit-drawer-footer-btns {
display: flex;
justify-content: flex-end;
@@ -1902,15 +1416,21 @@ onMounted(() => {
max-width: 100vw;
.el-drawer__header {
margin-bottom: 0;
padding: 20px 24px;
padding: 22px 28px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-drawer__body { padding: 24px; }
.el-drawer__body { padding: 20px 24px; background: #f5f7fa; }
.el-drawer__footer {
padding: 16px 24px;
padding: 16px 28px;
background: #fff;
border-top: 1px solid var(--el-border-color-lighter);
}
.el-form-item__content { min-width: 0; }
.benefit-drawer-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; line-height: 26px; }
.benefit-drawer-heading p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 20px; }
.benefit-drawer-footer-btns .el-button { min-width: 92px; }
.benefit-editor-form .tinymce-container { width: 100%; }
.benefit-editor-form .tinymce-container > .wrapper { margin-bottom: 10px; }
}
@@ -2065,30 +1585,3 @@ onMounted(() => {
}
</style>
<style lang="scss" scoped>
.benefit-logo-field {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.benefit-logo-upload {
flex: 1;
min-width: 200px;
}
</style>

View File

@@ -76,9 +76,11 @@
</el-form-item>
<el-form-item label="等级图标" prop="gradeImage">
<upload-pic-input v-model="formAdd.gradeImage" />
<div class="image-size-hint">推荐 100 × 100 像素,使用透明背景 PNG。</div>
</el-form-item>
<el-form-item label="等级背景图" prop="gradeBackground">
<upload-pic-input v-model="formAdd.gradeBackground" />
<div class="image-size-hint">推荐 750 × 460 像素</div>
</el-form-item>
<el-form-item label="字体颜色" prop="gradeFontColor">
<el-input v-model="formAdd.gradeFontColor" maxlength="20" placeholder="如:#333333" />
@@ -133,9 +135,11 @@
</el-form-item>
<el-form-item label="等级图标" prop="gradeImage">
<upload-pic-input v-model="formEdit.gradeImage" />
<div class="image-size-hint">推荐 100 × 100 像素,使用透明背景 PNG。</div>
</el-form-item>
<el-form-item label="等级背景图" prop="gradeBackground">
<upload-pic-input v-model="formEdit.gradeBackground" />
<div class="image-size-hint">推荐 750 × 460 像素</div>
</el-form-item>
<el-form-item label="字体颜色" prop="gradeFontColor">
<el-input v-model="formEdit.gradeFontColor" maxlength="20" placeholder="如:#333333" />
@@ -456,6 +460,13 @@ onMounted(() => {
});
</script>
<style scoped>
.image-size-hint {
width: 100%;
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
.ops a {
color: #2d8cf0;
cursor: pointer;

View File

@@ -986,7 +986,9 @@ function handleSelectConfirm() {
return;
}
const last = selectedOss.value[selectedOss.value.length - 1];
emit("callback", { url: last.split(",")[1] });
const separator = last.indexOf(",");
const id = last.slice(0, separator);
emit("callback", { url: last.slice(separator + 1), file: data.value.find(item => String(item.id) === id) });
}
function handleSelectCancel() {