fix(优惠券选择): 契约组件支持 receiveType 筛选,新人礼仅可选系统赠送券

嵌入选择页按内容高度自适应,表格列宽收紧便于弹窗展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-26 18:26:22 +08:00
parent 519f6550d8
commit 1d87201680
3 changed files with 194 additions and 39 deletions

View File

@ -95,26 +95,34 @@ class FormBuilder extends Form
}
/**
* 优惠券标签选择器(form-create 自定义组件)
* 优惠券标签选择器(form-create 自定义组件 · 契约组件)
*
* 前端 SchemaForm 已注册 type=SchemaCoupon 的自定义组件:
* el-tag 标签展示已选优惠券(可删除),弹窗打开系统选择优惠券页回填
* (/admin/marketing.Coupon/list.html,弹窗页脚确定/取消触发回填)。
* 值为对象数组 [{id, title, coupon_price, use_min_price}],
* 发券消费端(userFirstSubGiveCoupon)已兼容该格式。
* ⚠️ 契约组件:改前端只能向后兼容扩展(新增可选 props、默认行为不变)。
* 已支持 props:
* - receiveType:筛选发送方式(1 用户领取 / 3 系统赠送),默认 '' 不限;
* 新人礼等系统赠送场景应显式传 3。
*
* @param string $field 字段名
* @param string $title 字段标题
* @param array $value 已选优惠券对象数组
* @param array $props 组件 props 覆盖:receiveType 等
* @return \FormBuilder\Driver\CustomComponent
*/
public static function couponSelect($field, $title, $value = [])
public static function couponSelect($field, $title, $value = [], array $props = [])
{
return self::createComponent('SchemaCoupon')
->field($field)
->title($title)
->value((array)$value)
->props(['field' => $field]);
->props(array_merge([
'field' => $field,
'receiveType' => '',
], $props));
}
/**

View File

@ -8,13 +8,23 @@
<el-button type="primary" plain size="small" v-db-click @click="open">选择优惠券</el-button>
</div>
<el-dialog v-model="visible" title="选择优惠券" width="950px" append-to-body destroy-on-close>
<el-dialog
v-model="visible"
title="选择优惠券"
width="980px"
append-to-body
destroy-on-close
class="schema-coupon-dialog"
align-center
@closed="onClosed"
>
<iframe
v-if="visible"
ref="frameRef"
:src="iframeSrc"
frameborder="0"
class="schema-coupon__frame"
:style="{ height: `${frameHeight}px` }"
@load="onFrameLoad"
></iframe>
<template #footer>
@ -27,20 +37,30 @@
<script setup>
/**
* 通用页面优惠券标签选择器(form-create 自定义组件)
* 通用页面优惠券标签选择器(form-create 自定义组件 · 契约组件)
*
* el-tag 标签展示已选优惠券(可删除),弹窗以 iframe 打开系统选择优惠券页
* (/admin/marketing.Coupon/list.html?fodder=<field>)。iframe 加载后向其
* contentWindow 注入 form_create_helper(与 form-create frame 组件同协议),
* 选择页确认时经 helper.set 回传选中行数据、弹窗页脚"确定"经 helper.onOk
* 触发选择页的确认逻辑。值为对象数组 [{id, title, coupon_price, use_min_price}],
* 后端经 Form::couponSelect() 生成规则。
* (/admin/marketing.Coupon/list.html?fodder=<field>&embed=1&receive_type=<n>)。
* iframe 加载后向其 contentWindow 注入 form_create_helper(与 form-create frame
* 组件同协议),选择页确认时经 helper.set 回传选中行数据、弹窗页脚"确定"经
* helper.onOk 触发选择页的确认逻辑。值为对象数组
* [{id, title, coupon_price, use_min_price}],后端经 Form::couponSelect() 生成规则。
*
* 向后兼容扩展 props:
* - receiveType:筛选发送方式(1 用户领取 / 3 系统赠送),默认 '' 不限
*
* 嵌入页通过 postMessage({ type: 'schema-coupon-resize', height }) 回传内容高度,
* 弹窗 iframe 随数据行数收缩(表格最多 6 行后出滚动条)。
*/
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import setting from '@/setting';
defineOptions({ name: 'SchemaCoupon' });
/** iframe 初始高度(搜索区 + 空表提示),随后由嵌入页 resize 消息校正 */
const FRAME_HEIGHT_MIN = 160;
const FRAME_HEIGHT_MAX = 520;
const props = defineProps({
/** 已选优惠券对象数组 [{id, title, ...}](form-create field 值绑定) */
modelValue: {
@ -52,6 +72,14 @@ const props = defineProps({
type: String,
default: '',
},
/**
* 发送方式筛选:1 用户领取 / 3 系统赠送;空字符串表示不限
* (新人礼等系统赠送场景由 Form::couponSelect props.receiveType=3 传入)
*/
receiveType: {
type: [Number, String],
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
@ -61,11 +89,21 @@ const visible = ref(false);
/** 弹窗页脚"确定"触发的选择页回调列表(选择页经 helper.onOk 注册) */
const okCallbacks = ref([]);
const frameRef = ref(null);
const frameHeight = ref(280);
/** 回填协议标识:与规则 field 一致 */
const fodder = computed(() => props.field);
/** 选择优惠券页地址(带 .html 后缀命中前端路由) */
const iframeSrc = computed(() => `${setting.routePre}/marketing.Coupon/list.html?fodder=${fodder.value}`);
/** 选择优惠券页地址(embed=1 隐藏页内重复页脚,由本弹窗页脚确认) */
const iframeSrc = computed(() => {
const query = new URLSearchParams({
fodder: fodder.value || '',
embed: '1',
});
if (props.receiveType !== '' && props.receiveType !== null && props.receiveType !== undefined) {
query.set('receive_type', String(props.receiveType));
}
return `${setting.routePre}/marketing.Coupon/list.html?${query.toString()}`;
});
/** 已选标签数据(兼容历史纯 id 格式,无券名时显示 ID 兜底) */
const tags = computed(() =>
@ -82,9 +120,15 @@ function remove(index) {
/** 打开选择弹窗(destroy-on-close 会重建 iframe,历史回调一并失效清空) */
function open() {
okCallbacks.value = [];
frameHeight.value = 280;
visible.value = true;
}
function onClosed() {
okCallbacks.value = [];
frameHeight.value = 280;
}
/**
* iframe 加载完成:向其 contentWindow 注入 form_create_helper
* (与 form-create frame 组件相同的回填协议)
@ -118,6 +162,23 @@ function onOk() {
}
okCallbacks.value.forEach((fn) => fn());
}
/** 接收嵌入页高度回传,弹窗随列表行数收缩 */
function onMessage(event) {
const data = event?.data;
if (!data || data.type !== 'schema-coupon-resize') return;
const height = Number(data.height);
if (!Number.isFinite(height) || height <= 0) return;
frameHeight.value = Math.min(FRAME_HEIGHT_MAX, Math.max(FRAME_HEIGHT_MIN, Math.ceil(height)));
}
onMounted(() => {
window.addEventListener('message', onMessage);
});
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage);
});
</script>
<style lang="scss" scoped>
@ -141,9 +202,20 @@ function onOk() {
}
&__frame {
display: block;
width: 100%;
height: 540px;
border: 0 none;
vertical-align: top;
}
}
</style>
<style lang="scss">
/* append-to-body 弹窗样式需非 scoped */
.schema-coupon-dialog {
.el-dialog__body {
padding: 8px 12px 0;
overflow: hidden;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="coupon-select">
<div ref="rootRef" class="coupon-select" :class="{ 'is-embed': isEmbed }">
<el-form :inline="true" @submit.prevent>
<el-form-item label="优惠券名称:">
<el-input
@ -17,23 +17,23 @@
<el-table
ref="couponTable"
v-loading="loading"
class="mt15"
class="mt15 coupon-select__table"
:data="couponList"
:row-key="getRowKey"
highlight-current-row
no-userFrom-text="暂无数据"
no-filtered-userFrom-text="暂无筛选结果"
height="420"
:max-height="tableMaxHeight"
@selection-change="changeCheckbox"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" :reserve-selection="true" />
<el-table-column label="ID" width="70" prop="id" />
<el-table-column label="优惠券名称" min-width="140">
<el-table-column type="selection" width="42" :selectable="checkSelectable" :reserve-selection="true" />
<el-table-column label="ID" width="54" prop="id" />
<el-table-column label="优惠券名称" min-width="96" show-overflow-tooltip>
<template #default="scope">
<span>{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="优惠券类型" min-width="90">
<el-table-column label="类型" width="64">
<template #default="scope">
<span v-if="scope.row.type === 1">品类券</span>
<span v-else-if="scope.row.type === 2">商品券</span>
@ -41,36 +41,37 @@
<span v-else>通用券</span>
</template>
</el-table-column>
<el-table-column label="面值" min-width="80">
<el-table-column label="面值" width="64">
<template #default="scope">
<span>{{ scope.row.coupon_price }}元</span>
</template>
</el-table-column>
<el-table-column label="最低消费" min-width="90">
<el-table-column label="门槛" width="72">
<template #default="scope">
<span>{{ scope.row.use_min_price == '0.00' ? '无门槛' : `满${scope.row.use_min_price}元` }}</span>
<span>{{ scope.row.use_min_price == '0.00' ? '无门槛' : `满${scope.row.use_min_price}` }}</span>
</template>
</el-table-column>
<el-table-column label="发布数量" min-width="130">
<el-table-column label="数量" width="88">
<template #default="scope">
<span v-if="scope.row.is_permanent">不限量</span>
<div v-else>
<span>发布:{{ scope.row.total_count }}</span>
<span class="ml10">剩余:{{ scope.row.remain_count }}</span>
</div>
<span v-else class="cell-nowrap">{{ scope.row.total_count }}/{{ scope.row.remain_count }}</span>
</template>
</el-table-column>
<el-table-column label="领取期限" min-width="150">
<el-table-column label="领取期限" width="178">
<template #default="scope">
<div v-if="scope.row.start_time || scope.row.end_time">
{{ $filters.formatDate(scope.row.start_time) }} ~ {{ $filters.formatDate(scope.row.end_time) }}
</div>
<span v-if="scope.row.start_time || scope.row.end_time" class="cell-nowrap">
{{ formatDay(scope.row.start_time) }}~{{ formatDay(scope.row.end_time) }}
</span>
<span v-else>不限时</span>
</template>
</el-table-column>
<el-table-column label="使用有效期" min-width="150">
<el-table-column label="使用有效期" width="178">
<template #default="scope">
<span>{{ scope.row.use_time }}</span>
<!-- 有固定使用起止时间用日期(仅年月日);否则按领取后天数展示 -->
<span v-if="scope.row.start_use_time" class="cell-nowrap">
{{ formatDay(scope.row.start_use_time) }}~{{ formatDay(scope.row.end_use_time) }}
</span>
<span v-else>{{ Number(scope.row.coupon_time || 0) }}天</span>
</template>
</el-table-column>
</el-table>
@ -83,7 +84,8 @@
@pagination="getList"
/>
</div>
<div class="footer">
<!-- 嵌入 SchemaCoupon 弹窗时由外层页脚确认,避免重复按钮与高度浪费 -->
<div v-if="!isEmbed" class="footer">
<el-button v-db-click @click="cancel">取 消</el-button>
<el-button type="primary" v-db-click @click="confirm"
>确认选择{{ selectedRows.length ? `(${selectedRows.length})` : '' }}</el-button
@ -93,7 +95,7 @@
</template>
<script setup>
import { nextTick, onMounted, reactive, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { useRoute } from 'vue-router';
import { releasedListApi } from '@/api/marketing';
@ -102,19 +104,56 @@ defineOptions({ name: 'CouponSelect' });
const route = useRoute();
/** 是否嵌入 SchemaCoupon 弹窗(由 iframe query embed=1 标识) */
const isEmbed = computed(() => String(route.query.embed || '') === '1');
/** 表头约 40px + 单行约 48px,最多展示 6 行后出滚动条 */
const TABLE_HEADER_HEIGHT = 40;
const TABLE_ROW_HEIGHT = 48;
const TABLE_MAX_ROWS = 6;
const tableMaxHeight = TABLE_HEADER_HEIGHT + TABLE_ROW_HEIGHT * TABLE_MAX_ROWS;
const loading = ref(false);
const total = ref(0);
const couponList = ref([]);
const couponTable = ref(null);
const rootRef = ref(null);
const tableFrom = reactive({
coupon_title: '',
receive_type: '',
// 支持 URL receive_type:新人礼传 3(系统赠送);缺省不限,保持历史行为
receive_type: route.query.receive_type !== undefined && route.query.receive_type !== '' ? route.query.receive_type : '',
page: 1,
limit: 15,
});
/** 已选行完整数据([{id,title,coupon_price,use_min_price}],经 helper.set 回传展示券名) */
const selectedRows = ref([]);
/**
* 时间戳格式化为仅年月日(兼容秒/毫秒),用于期限列完整单行展示
* @param {number|string} data
* @returns {string}
*/
function formatDay(data) {
if (data === null || data === undefined || data === '' || Number(data) === 0) {
return '';
}
let ts = Number(data);
if (!Number.isFinite(ts)) {
return '';
}
if (ts > 0 && ts < 1e12) {
ts *= 1000;
}
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
return '';
}
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}/${m}/${d}`;
}
/**
* 余量充足且在领取期内的券才允许勾选(与后端发券查询 getGiveCoupon 条件一致)
*/
@ -133,17 +172,32 @@ function getRowKey(row) {
return row.id;
}
/** 通知外层弹窗按内容高度收缩 iframe,避免大片空白 */
function reportHeight() {
if (!isEmbed.value || !window.parent || window.parent === window) return;
nextTick(() => {
const el = rootRef.value;
if (!el) return;
const height = Math.ceil(el.getBoundingClientRect().height);
window.parent.postMessage({ type: 'schema-coupon-resize', height }, '*');
});
}
function getList() {
loading.value = true;
releasedListApi(tableFrom)
.then((res) => {
couponList.value = res.data.list;
total.value = res.data.count;
nextTick(() => setCheckedRows());
nextTick(() => {
setCheckedRows();
reportHeight();
});
loading.value = false;
})
.catch(() => {
loading.value = false;
reportHeight();
});
}
@ -211,6 +265,8 @@ function waitHelper(timeout = 5000) {
});
}
watch(loading, () => reportHeight());
onMounted(async () => {
const helper = await waitHelper();
if (helper) {
@ -225,6 +281,11 @@ onMounted(async () => {
helper.onOk(confirm);
}
getList();
window.addEventListener('resize', reportHeight);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', reportHeight);
});
</script>
@ -232,6 +293,20 @@ onMounted(async () => {
.coupon-select {
padding: 15px 15px 0;
background-color: var(--prev-bg-white);
&.is-embed {
padding: 4px 4px 0;
min-height: 0;
}
}
.coupon-select__table {
width: 100%;
}
.cell-nowrap {
white-space: nowrap;
}
.page {
margin-top: 8px;
}
.footer {
padding: 15px 0;