mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-06 17:09:08 +08:00
feat(coupon): 重构优惠券类型为满减、满折、立减三种分类
- 新增立减优惠券类型,支持无门槛直接抵扣 - 优惠券列表页统一使用行对象判断类型并显示完整类型名称 - 发布页增加优惠券类型选择组件,切换时自动联动字段显隐 - 买家端购物车满减门槛为零时显示"无门槛使用" - 卖家端发布页新增券面实时预览面板 - 卖家端发布页重构为分步布局并优化表单交互样式 - 面额与消费门槛校验增加大于零的数值约束 - 移除列表页冗余的标签颜色映射函数,简化展示逻辑
This commit is contained in:
47
README.md
47
README.md
@@ -162,19 +162,42 @@ lilishop-ui
|
|||||||
|
|
||||||
## 8. 升级计划
|
## 8. 升级计划
|
||||||
|
|
||||||
> 更新日期:2026-11-04,依据各端近期提交及当前代码实现同步。
|
|
||||||
|
|
||||||
### ✅ 已完成特性
|
### ✅ 已完成特性
|
||||||
| 功能模块 | 功能模块 |
|
|
||||||
| :--- | :--- |
|
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
|
||||||
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
|
|
||||||
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
|
| 时间 | 功能模块 | 完成内容 |
|
||||||
| ✅ 置顶评论 | ✅ 客户等级体系 |
|
| :--- | :--- | :--- |
|
||||||
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
|
| 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||||
| ✅ 卡密商品 | ✅ 二级分销 |
|
| 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||||
| ✅ 定时任务管理(数据库配置驱动) | ✅ 预约服务商品 |
|
| 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
||||||
| ✅ 主题色管理 | ✅ 立减优惠券 |
|
| 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
||||||
| ✅ 小程序订单管理(简单版) | ✅ 微信订单自动上报开关 |
|
| 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
||||||
|
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
||||||
|
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
||||||
|
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
||||||
|
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
||||||
|
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
||||||
|
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
||||||
|
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
||||||
|
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
||||||
|
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
|
||||||
|
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
||||||
|
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
||||||
|
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
||||||
|
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
||||||
|
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
||||||
|
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
||||||
|
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
||||||
|
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
||||||
|
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
||||||
|
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
||||||
|
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
||||||
|
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
|
||||||
|
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
||||||
|
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
||||||
|
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
|
||||||
|
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
|
||||||
|
|
||||||
### 🔜 近期规划中
|
### 🔜 近期规划中
|
||||||
- 🔜 微信小店对接
|
- 🔜 微信小店对接
|
||||||
|
|||||||
6
manager/src/api/paid-member.js
Normal file
6
manager/src/api/paid-member.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { getRequest, putRequestWithNoForm } from '@/libs/axios';
|
||||||
|
export const getPaidConfig = () => getRequest('/member/paid/config');
|
||||||
|
export const savePaidConfig = data => putRequestWithNoForm('/member/paid/config', data);
|
||||||
|
export const getPaidOrders = params => getRequest('/member/paid/orders', params);
|
||||||
|
export const getPaidResources = (type, ids) => getRequest('/member/paid/resources', { type, ids: ids.join(',') });
|
||||||
|
export const getPaidDiscountSkus = params => getRequest('/member/paid/discount-skus', params);
|
||||||
@@ -92,6 +92,9 @@
|
|||||||
<span>{{ row.simpleSpecs || "默认规格" }}</span>
|
<span>{{ row.simpleSpecs || "默认规格" }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="店铺名称" prop="storeName" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.storeName || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="销售价" width="120" align="right">
|
<el-table-column label="销售价" width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="goods-price font-semibold">
|
<span class="goods-price font-semibold">
|
||||||
@@ -111,7 +114,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="商品类型" width="100" align="center">
|
<el-table-column label="商品类型" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" type="info">{{ goodsTypeLabel(row.goodsType) }}</el-tag>
|
<span>{{ goodsTypeLabel(row.goodsType) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -136,11 +139,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, reactive, ref, watch } from "vue";
|
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||||
import * as API_Goods from "@/api/goods";
|
import * as API_Goods from "@/api/goods";
|
||||||
import { goodsTypeLabel } from "@/constants/goodsType";
|
import { goodsTypeLabel } from "@/constants/goodsType";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
goodsLoader: { type: Function, default: null },
|
||||||
|
queryParams: { type: Object, default: () => ({}) },
|
||||||
selectedWay: {
|
selectedWay: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -167,6 +172,8 @@ const category = ref([]);
|
|||||||
const goodsData = ref([]);
|
const goodsData = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const tableRef = ref(null);
|
const tableRef = ref(null);
|
||||||
|
let syncingSelection = false;
|
||||||
|
let querySequence = 0;
|
||||||
|
|
||||||
watch(category, (val) => {
|
watch(category, (val) => {
|
||||||
goodsParams.categoryPath = val && val.length ? val[2] : "";
|
goodsParams.categoryPath = val && val.length ? val[2] : "";
|
||||||
@@ -194,6 +201,8 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
|
watch(() => props.queryParams, onSearchGoods, { deep: true });
|
||||||
|
onUnmounted(() => { querySequence++; });
|
||||||
|
|
||||||
function onSearchGoods() {
|
function onSearchGoods() {
|
||||||
goodsData.value = [];
|
goodsData.value = [];
|
||||||
@@ -222,13 +231,14 @@ function onPageSizeChange(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getQueryGoodsList() {
|
function getQueryGoodsList() {
|
||||||
|
const sequence = ++querySequence;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
API_Goods.getGoodsSkuData(goodsParams)
|
(props.goodsLoader || API_Goods.getGoodsSkuData)({ ...goodsParams, ...props.queryParams })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
initGoods(res);
|
if (sequence === querySequence) initGoods(res);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loading.value = false;
|
if (sequence === querySequence) loading.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +255,18 @@ function initGoods(res) {
|
|||||||
function syncTableSelection() {
|
function syncTableSelection() {
|
||||||
const table = tableRef.value;
|
const table = tableRef.value;
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
if (!selectedList.value.length) {
|
// 将不在当前页的已选 SKU 也加入保留选择,避免翻页回显时丢失。
|
||||||
|
syncingSelection = true;
|
||||||
|
try {
|
||||||
table.clearSelection();
|
table.clearSelection();
|
||||||
return;
|
selectedList.value.forEach((item) => {
|
||||||
|
const id = String(item.id || item.skuId);
|
||||||
|
const current = goodsData.value.find(row => String(row.id || row.skuId) === id);
|
||||||
|
table.toggleRowSelection(current || item, true);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
syncingSelection = false;
|
||||||
}
|
}
|
||||||
goodsData.value.forEach((item) => {
|
|
||||||
const selected = selectedList.value.some(
|
|
||||||
(e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId)
|
|
||||||
);
|
|
||||||
table.toggleRowSelection(item, selected);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitSelected(list) {
|
function emitSelected(list) {
|
||||||
@@ -263,6 +275,7 @@ function emitSelected(list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSelectionChange(rows) {
|
function onSelectionChange(rows) {
|
||||||
|
if (syncingSelection) return;
|
||||||
if (type.value !== "multiple") {
|
if (type.value !== "multiple") {
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
emitSelected([]);
|
emitSelected([]);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
v-if="flag"
|
v-if="flag"
|
||||||
ref="goodsDialogRef"
|
ref="goodsDialogRef"
|
||||||
:selectedWay="goodsData"
|
:selectedWay="goodsData"
|
||||||
|
:goods-loader="goodsLoader"
|
||||||
|
:query-params="queryParams"
|
||||||
@selected="
|
@selected="
|
||||||
(val) => {
|
(val) => {
|
||||||
goodsData = val;
|
goodsData = val;
|
||||||
@@ -69,6 +71,7 @@ import goodsDialog from "./goods-dialog";
|
|||||||
import linkDialog from "./link-dialog";
|
import linkDialog from "./link-dialog";
|
||||||
|
|
||||||
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
|
const emit = defineEmits(["closeFlag", "selectedGoodsData", "selectedLink"]);
|
||||||
|
defineProps({ goodsLoader: { type: Function, default: null }, queryParams: { type: Object, default: () => ({}) } });
|
||||||
|
|
||||||
const goodsFlag = ref(false); // 是否商品选择器
|
const goodsFlag = ref(false); // 是否商品选择器
|
||||||
const goodsData = ref([]); // 选择的商品
|
const goodsData = ref([]); // 选择的商品
|
||||||
|
|||||||
@@ -1,174 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shop">
|
<StoreSelector embedded :multiple="false" :query-params="shopQuery" @selected="selectShop" />
|
||||||
<div class="wap-content">
|
|
||||||
<div class="query-wrapper">
|
|
||||||
<div class="query-item">
|
|
||||||
<div>店铺名称</div>
|
|
||||||
<el-input
|
|
||||||
v-model="params.storeName"
|
|
||||||
placeholder="请输入店铺名称"
|
|
||||||
clearable
|
|
||||||
style="width: 150px"
|
|
||||||
@clear="resetSearch"
|
|
||||||
@keyup.enter="resetSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="query-item">
|
|
||||||
<el-button type="primary" @click="resetSearch">搜索</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<el-table
|
|
||||||
ref="tableRef"
|
|
||||||
v-loading="loading"
|
|
||||||
class="shops-select-table"
|
|
||||||
:data="shopsData"
|
|
||||||
row-key="id"
|
|
||||||
height="340"
|
|
||||||
empty-text="暂无店铺信息"
|
|
||||||
@selection-change="onSelectionChange"
|
|
||||||
>
|
|
||||||
<el-table-column
|
|
||||||
type="selection"
|
|
||||||
label="选择"
|
|
||||||
width="60"
|
|
||||||
align="center"
|
|
||||||
reserve-selection
|
|
||||||
/>
|
|
||||||
<el-table-column label="店铺图片" width="100" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<img class="shop-logo" :src="row.storeLogo" alt="" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="storeName"
|
|
||||||
label="店铺名称"
|
|
||||||
min-width="240"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="createTime"
|
|
||||||
label="创建时间"
|
|
||||||
width="180"
|
|
||||||
show-overflow-tooltip
|
|
||||||
/>
|
|
||||||
<el-table-column label="店铺类型" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :class="{ theme_color: row.selfOperated }">
|
|
||||||
{{ row.selfOperated ? "自营" : "非自营" }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="店铺状态" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :class="{ theme_color: row.storeDisable === 'OPEN' }">
|
|
||||||
{{ row.storeDisable === "OPEN" ? "开启中" : "未开启" }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-pagination
|
|
||||||
class="pageration"
|
|
||||||
size="small"
|
|
||||||
layout="total, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
:page-size="params.pageSize"
|
|
||||||
:current-page="params.pageNumber"
|
|
||||||
@current-change="changePageSize"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, reactive, ref } from "vue";
|
import StoreSelector from '@/components/store-selector/index.vue';
|
||||||
import { getShopListData } from "@/api/shops.js";
|
// Author: Bulbasaur; 2026-11-05. 装修链接复用统一店铺选择组件,保留单选及启用店铺范围。
|
||||||
|
const emit = defineEmits(['selected']);
|
||||||
const emit = defineEmits(["selected"]);
|
const shopQuery = { storeDisable: 'OPEN' };
|
||||||
|
function selectShop(rows) { emit('selected', rows.map(row => ({ ...row, ___type: 'shops' }))); }
|
||||||
const loading = ref(false);
|
|
||||||
const total = ref(0);
|
|
||||||
const params = reactive({
|
|
||||||
pageNumber: 1,
|
|
||||||
pageSize: 12,
|
|
||||||
storeDisable: "OPEN",
|
|
||||||
storeName: "",
|
|
||||||
});
|
|
||||||
const shopsData = ref([]);
|
|
||||||
const tableRef = ref(null);
|
|
||||||
const selectedShop = ref(null);
|
|
||||||
|
|
||||||
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
|
|
||||||
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
|
|
||||||
|
|
||||||
// created
|
|
||||||
init();
|
|
||||||
|
|
||||||
function resetSearch() {
|
|
||||||
shopsData.value = [];
|
|
||||||
params.pageNumber = 1;
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePageSize(v) {
|
|
||||||
params.pageNumber = v;
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
loading.value = true;
|
|
||||||
getShopListData(params)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.success) {
|
|
||||||
total.value = res.result.total;
|
|
||||||
shopsData.value = res.result.records;
|
|
||||||
nextTick(syncTableSelection);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading.value = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncTableSelection() {
|
|
||||||
const table = tableRef.value;
|
|
||||||
if (!table || !selectedShop.value) return;
|
|
||||||
shopsData.value.forEach((row) => {
|
|
||||||
table.toggleRowSelection(row, row.id === selectedShop.value.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectionChange(rows) {
|
|
||||||
// 选择链接只能保留一个店铺,勾选新店铺时自动取消上一个
|
|
||||||
if (!rows.length) {
|
|
||||||
selectedShop.value = null;
|
|
||||||
emit("selected", []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = rows[rows.length - 1];
|
|
||||||
if (rows.length > 1) {
|
|
||||||
tableRef.value?.clearSelection();
|
|
||||||
tableRef.value?.toggleRowSelection(row, true);
|
|
||||||
}
|
|
||||||
selectedShop.value = row;
|
|
||||||
emit("selected", [{ ...row, ___type: "shops" }]);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "../style.scss";
|
|
||||||
.shop {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.shop-logo {
|
|
||||||
display: block;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.pageration {
|
|
||||||
margin-top: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
25
manager/src/components/store-selector/README.md
Normal file
25
manager/src/components/store-selector/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 统一店铺选择器
|
||||||
|
|
||||||
|
作者:Bulbasaur;2026-11-05。
|
||||||
|
|
||||||
|
默认从右侧打开抽屉,支持搜索、分页、多选和已选回显。取消不提交,确认通过 `selected` 返回完整已选店铺数组。店铺类型、状态均为普通文字,无右侧预览区。
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StoreSelector ref="selector" @selected="onSelected" />
|
||||||
|
```
|
||||||
|
|
||||||
|
导入 `@/components/store-selector/index.vue`,通过 `selector.value.open(selectedRows)` 打开。`selectedRows` 为包含 `id` 的店铺对象数组;每次打开建立独立选择草稿。
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| multiple | true | false 时单选,返回数组最多一项 |
|
||||||
|
| embedded | false | true 时在父容器展示列表,并在选择时立即通知;适用于装修链接面板 |
|
||||||
|
| selectedWay | [] | 初始店铺数组;内嵌模式监听外部选择变化 |
|
||||||
|
| queryParams | {} | 店铺查询限制,例如 `{ storeDisable: 'OPEN' }` |
|
||||||
|
| title | 选择店铺 | 抽屉标题 |
|
||||||
|
| size | 1080px | 抽屉宽度,最大为视口宽度 |
|
||||||
|
| tableHeight | 自动 | 可选表格高度 |
|
||||||
|
|
||||||
|
已接入付费会员、装修链接单选、移动端装修店铺列表配置。查询复用 `getShopListData`(`/store/store`),不新增后端接口。
|
||||||
|
|
||||||
|
验证:在工作区根目录运行 `node scripts/test-store-selector.cjs`。
|
||||||
115
manager/src/components/store-selector/index.vue
Normal file
115
manager/src/components/store-selector/index.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="embedded ? 'div' : ElDrawer" v-bind="embedded ? {} : { modelValue: visible, title, size, direction: 'rtl', appendToBody: true, destroyOnClose: true }" :class="embedded ? 'store-selector-inline' : 'store-picker-drawer'" @update:model-value="visible = $event" @close="request++">
|
||||||
|
<el-form class="picker-search" inline @submit.prevent="search">
|
||||||
|
<el-form-item label="店铺名称"><el-input v-model="keyword" clearable placeholder="请输入店铺名称" /></el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="picker-content">
|
||||||
|
<div class="picker-results">
|
||||||
|
<el-alert v-if="error" title="加载失败,请重试" type="error" :closable="false"><el-button link type="primary" @click="load">重新加载</el-button></el-alert>
|
||||||
|
<el-table ref="table" v-loading="loading" :data="rows" row-key="id" border :height="tableHeight || (embedded ? '340px' : 'max(300px, calc(100vh - 270px))')" :empty-text="error ? '加载失败' : '暂无匹配结果'" @select="selectStore" @select-all="selectPage" @row-click="toggleStore">
|
||||||
|
<el-table-column v-if="multiple" type="selection" :reserve-selection="false" width="48" align="center" />
|
||||||
|
<el-table-column v-else label="选择" width="60" align="center"><template #default="{row}"><el-radio :model-value="selectedStores[0]?.id" :value="row.id" :aria-label="'选择' + row.storeName" @click.stop @change="chooseSingle(row)" /></template></el-table-column>
|
||||||
|
<el-table-column label="店铺信息" min-width="265"><template #default="{row}"><div class="store-info"><el-avatar :size="40" shape="square" :src="row.storeLogo || ''">店</el-avatar><div class="store-copy"><div class="store-name" :title="row.storeName">{{ row.storeName || '未命名店铺' }}</div><div class="store-id">ID:{{ row.id }}</div></div></div></template></el-table-column>
|
||||||
|
<el-table-column label="店铺类型" width="96" align="center"><template #default="{row}">{{ selfOperated(row) ? '自营' : '非自营' }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center"><template #default="{row}">{{ storeStatus(row.storeDisable) }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="page" :total="total" :page-size="10" layout="total, prev, pager, next" @current-change="load" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="!embedded" #footer><div class="picker-footer"><span class="selected-count">已选择 <b>{{ selectedStores.length }}</b> 家店铺</span><div><el-button @click="visible = false">取消</el-button><el-button type="primary" :disabled="loading" @click="confirm">确定</el-button></div></div></template>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
import { ElDrawer } from 'element-plus';
|
||||||
|
import { getShopListData } from '@/api/shops';
|
||||||
|
// Author: Bulbasaur; 2026-11-05.
|
||||||
|
const props = defineProps({
|
||||||
|
multiple: { type: Boolean, default: true },
|
||||||
|
embedded: { type: Boolean, default: false },
|
||||||
|
selectedWay: { type: Array, default: () => [] },
|
||||||
|
queryParams: { type: Object, default: () => ({}) },
|
||||||
|
title: { type: String, default: '选择店铺' },
|
||||||
|
size: { type: [String, Number], default: '1080px' },
|
||||||
|
tableHeight: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['selected']);
|
||||||
|
const visible = ref(false), keyword = ref(''), rows = ref([]), total = ref(0), page = ref(1), loading = ref(false), error = ref(false), table = ref();
|
||||||
|
const storeSelection = ref({});
|
||||||
|
const selectedStores = computed(() => Object.values(storeSelection.value));
|
||||||
|
let request = 0;
|
||||||
|
const selfOperated = row => [true, 1, 'true', '1'].includes(row.selfOperated);
|
||||||
|
const storeStatus = value => ({OPEN:'营业中',CLOSED:'已关闭',APPLY:'待审核',APPLYING:'待审核',REFUSED:'审核拒绝',EXPIRED:'已过期'}[value] || value || '—');
|
||||||
|
async function syncStores() {
|
||||||
|
await nextTick();
|
||||||
|
if (!table.value) return;
|
||||||
|
table.value.clearSelection();
|
||||||
|
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) table.value.toggleRowSelection(row, true); });
|
||||||
|
}
|
||||||
|
function notifyEmbedded() { if (props.embedded) emit('selected', selectedStores.value.map(row => ({...row}))); }
|
||||||
|
function chooseSingle(row) { storeSelection.value = { [String(row.id)]: {...row} }; notifyEmbedded(); }
|
||||||
|
function selectStore(selected, row) {
|
||||||
|
const key = String(row.id);
|
||||||
|
if (selected.some(item => String(item.id) === key)) storeSelection.value[key] = row;
|
||||||
|
else delete storeSelection.value[key];
|
||||||
|
notifyEmbedded();
|
||||||
|
}
|
||||||
|
function selectPage(selected) {
|
||||||
|
const ids = new Set(selected.map(row => String(row.id)));
|
||||||
|
rows.value.forEach(row => { if (ids.has(String(row.id))) storeSelection.value[String(row.id)] = row; else delete storeSelection.value[String(row.id)]; });
|
||||||
|
notifyEmbedded();
|
||||||
|
}
|
||||||
|
function toggleStore(row, column) {
|
||||||
|
if (column?.type === 'selection') return;
|
||||||
|
if (!props.multiple) { chooseSingle(row); return; }
|
||||||
|
const key = String(row.id);
|
||||||
|
if (storeSelection.value[key]) delete storeSelection.value[key]; else storeSelection.value[key] = row;
|
||||||
|
syncStores();
|
||||||
|
notifyEmbedded();
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
const id = ++request; loading.value = true; error.value = false;
|
||||||
|
try {
|
||||||
|
const res = await getShopListData({ ...props.queryParams, pageNumber: page.value, pageSize: 10, storeName: keyword.value.trim() });
|
||||||
|
if (id !== request || !visible.value) return;
|
||||||
|
if (!res.success) throw new Error('load');
|
||||||
|
rows.value = res.result.records || []; total.value = res.result.total || 0;
|
||||||
|
rows.value.forEach(row => { if (storeSelection.value[String(row.id)]) storeSelection.value[String(row.id)] = row; });
|
||||||
|
await syncStores();
|
||||||
|
} catch { if (id === request) { error.value = true; rows.value = []; total.value = 0; } }
|
||||||
|
finally { if (id === request) loading.value = false; }
|
||||||
|
}
|
||||||
|
function search() { page.value = 1; load(); }
|
||||||
|
function reset() { keyword.value = ''; search(); }
|
||||||
|
async function open(selected = props.selectedWay) {
|
||||||
|
request++; keyword.value = ''; rows.value = []; total.value = 0; page.value = 1; error.value = false;
|
||||||
|
setSelection(selected);
|
||||||
|
visible.value = true;
|
||||||
|
await nextTick(); table.value?.clearSelection();
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
function setSelection(selected) {
|
||||||
|
const valid = (Array.isArray(selected) ? selected : []).filter(row => row && row.id != null);
|
||||||
|
storeSelection.value = Object.fromEntries((props.multiple ? valid : valid.slice(0,1)).map(row => [String(row.id), {...row}]));
|
||||||
|
}
|
||||||
|
function confirm() { emit('selected', selectedStores.value.map(row => ({...row}))); visible.value = false; }
|
||||||
|
watch(() => props.selectedWay, selected => { if (props.embedded) { setSelection(selected); syncStores(); } }, { deep: true });
|
||||||
|
watch(() => props.queryParams, () => { if (visible.value) search(); }, { deep: true });
|
||||||
|
onMounted(() => { if (props.embedded) open(); });
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
:global(.store-picker-drawer) { max-width: 100vw; }
|
||||||
|
:global(.store-picker-drawer .el-drawer__header) { margin-bottom: 0; padding: 20px; border-bottom: 1px solid #ebeef5; }
|
||||||
|
:global(.store-picker-drawer .el-drawer__body) { padding: 20px; }
|
||||||
|
:global(.store-picker-drawer .el-drawer__footer) { padding: 0 20px 20px; }
|
||||||
|
.picker-search { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 16px 18px 0; margin-bottom: 18px; background: #f7f8fa; border-radius: 6px; }
|
||||||
|
.picker-search .el-form-item { margin-right: 0; margin-bottom: 16px; }.picker-search .el-input { width: 240px; }
|
||||||
|
.picker-content { min-width: 0; }.picker-results { min-width: 0; }
|
||||||
|
.el-pagination { margin-top: 18px; }.store-info { display: flex; align-items: center; gap: 12px; min-height: 50px; }.el-avatar { flex-shrink: 0; background: #edf2f8; color: #7b8da6; }
|
||||||
|
.store-copy { min-width: 0; flex: 1; }.store-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #303133; }.store-id { margin-top: 3px; font-size: 12px; color: #909399; overflow-wrap: anywhere; }
|
||||||
|
.selected-count b { color: var(--el-color-primary); }
|
||||||
|
.picker-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #ebeef5; padding-top: 16px; }.selected-count { color: #909399; }
|
||||||
|
@media(max-width: 800px) { .picker-search .el-input { width: 180px; } }
|
||||||
|
</style>
|
||||||
@@ -224,6 +224,14 @@ export const otherRouter = {
|
|||||||
name: "member-benefit",
|
name: "member-benefit",
|
||||||
component: () => import("@/views/member/benefit/index.vue")
|
component: () => import("@/views/member/benefit/index.vue")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "paid-member-settings", title: "付费会员", name: "paid-member-settings",
|
||||||
|
component: () => import("@/views/member/paid/settings.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "paid-member-records", title: "付费记录", name: "paid-member-records",
|
||||||
|
component: () => import("@/views/member/paid/records.vue")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "member-benefit-deliveries",
|
path: "member-benefit-deliveries",
|
||||||
title: "权益发放明细",
|
title: "权益发放明细",
|
||||||
@@ -242,7 +250,7 @@ export const otherRouter = {
|
|||||||
name: "member-grade-experience-log",
|
name: "member-grade-experience-log",
|
||||||
component: () => import("@/views/member/grade/experience-log.vue")
|
component: () => import("@/views/member/grade/experience-log.vue")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "goods/goods-info/goodsDetail",
|
path: "goods/goods-info/goodsDetail",
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
<!-- 权益编码 -->
|
<!-- 权益编码 -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1585,3 +1585,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
47
manager/src/views/member/paid/records.vue
Normal file
47
manager/src/views/member/paid/records.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search">
|
||||||
|
<el-card shadow="never"><el-form inline :model="query" class="search-form" @submit.prevent @keyup.enter="search">
|
||||||
|
<el-form-item label="客户名称"><el-input v-model="query.keyword" placeholder="请输入客户昵称" clearable /></el-form-item><el-form-item label="会员ID"><el-input v-model="query.memberId" placeholder="请输入会员ID" clearable /></el-form-item>
|
||||||
|
<el-form-item label="支付状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option label="未支付" value="UNPAID" /><el-option label="已支付" value="PAID" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||||
|
</el-form></el-card>
|
||||||
|
<el-card shadow="never" class="mt_10"><div class="actions"><el-button :loading="exporting" @click="exportRecords">导出记录</el-button><el-button @click="router.push({name:'paid-member-settings'})">付费会员</el-button></div>
|
||||||
|
<el-table v-loading="loading" :data="rows" border>
|
||||||
|
<el-table-column prop="sn" label="会员费订单号" min-width="200" /><el-table-column prop="memberName" label="客户" width="130" /><el-table-column prop="memberId" label="会员ID" min-width="180" />
|
||||||
|
<el-table-column label="会员名称" min-width="140"><template #default="{row}">{{ name(row) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="amount" label="付费金额(元)" width="115" /><el-table-column prop="months" label="有效月数" width="90" /><el-table-column label="支付状态" width="110"><template #default="{row}">{{ status(row) }}</template></el-table-column>
|
||||||
|
<el-table-column label="会员状态" width="110"><template #default="{row}">{{ membership(row) }}</template></el-table-column><el-table-column prop="payTime" label="支付时间" width="175" /><el-table-column prop="startTime" label="生效时间" width="175" /><el-table-column prop="endTime" label="到期时间" width="175" />
|
||||||
|
<el-table-column prop="paymentMethod" label="支付方式" width="115" /><el-table-column prop="createTime" label="创建时间" width="175" />
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="query.pageNumber" :page-size="20" :total="total" 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 } from 'element-plus';
|
||||||
|
import { getPaidOrders } from '@/api/paid-member';
|
||||||
|
defineOptions({name:'paidMemberRecords'});
|
||||||
|
const router=useRouter(), query=reactive({keyword:'',memberId:'',status:'',pageNumber:1,pageSize:20});
|
||||||
|
const rows=ref([]), total=ref(0), loading=ref(false), exporting=ref(false);
|
||||||
|
let request=0;
|
||||||
|
const time=value => value ? new Date(String(value).replace(/-/g,'/')).getTime() : 0;
|
||||||
|
function status(row) { return row.status === 'UNPAID' && time(row.payDeadline)<Date.now() ? '已超时' : ({UNPAID:'未支付',PAID:'已支付',CANCELLED:'已取消'}[row.status] || row.status); }
|
||||||
|
function membership(row) { return row.status !== 'PAID' ? '未开通' : time(row.startTime)>Date.now() ? '待生效' : time(row.endTime)<=Date.now() ? '已到期' : '有效'; }
|
||||||
|
function name(row) { try { return JSON.parse(row.configSnapshot).name; } catch { return '付费会员'; } }
|
||||||
|
async function load() { const id=++request;loading.value=true;try {const res=await getPaidOrders({...query});if(id===request && res.success){rows.value=res.result.records||[];total.value=res.result.total||0;}} finally {if(id===request)loading.value=false;} }
|
||||||
|
function search(){query.pageNumber=1;load();} function reset(){query.keyword='';query.memberId='';query.status='';search();}
|
||||||
|
async function exportRecords() {
|
||||||
|
exporting.value=true;
|
||||||
|
try {
|
||||||
|
const list=[], filters={...query}; let page=1;
|
||||||
|
while(true){const res=await getPaidOrders({...filters,pageNumber:page,pageSize:100});if(!res.success)return;const records=res.result.records||[];if(!records.length)break;list.push(...records);if(list.length>=res.result.total)break;if(list.length>=10000){ElMessage.warning('一次最多导出10000条,请缩小筛选范围');return;}page++;}
|
||||||
|
const quote=value=>{let text=String(value??'');if(/^[=+\-@]/.test(text))text="'"+text;return '"'+text.replace(/"/g,'""')+'"';};
|
||||||
|
const csv=[['会员费订单号','客户','会员ID','会员名称','付费金额','有效月数','支付状态','会员状态','支付时间','生效时间','到期时间','支付方式'],...list.map(r=>[r.sn,r.memberName,r.memberId,name(r),r.amount,r.months,status(r),membership(r),r.payTime,r.startTime,r.endTime,r.paymentMethod])].map(row=>row.map(quote).join(',')).join('\r\n');
|
||||||
|
const url=URL.createObjectURL(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}));const a=document.createElement('a');a.href=url;a.download='付费记录.csv';a.click();URL.revokeObjectURL(url);
|
||||||
|
} finally {exporting.value=false;}
|
||||||
|
}
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
<style scoped>.actions { display:flex; gap:12px; margin-bottom:18px; }.el-pagination { margin-top:20px;justify-content:flex-end; }</style>
|
||||||
159
manager/src/views/member/paid/settings.vue
Normal file
159
manager/src/views/member/paid/settings.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="paid-settings" v-loading="loading">
|
||||||
|
<el-alert v-if="loadError" title="设置加载失败,请点击重试" type="error" :closable="false"><el-button link @click="loadSettings">重新加载</el-button></el-alert>
|
||||||
|
<el-form :model="form" label-width="150px" @submit.prevent>
|
||||||
|
<el-card shadow="never"><template #header>基本信息</template>
|
||||||
|
<el-form-item label="开放购买/续费"><el-switch v-model="form.enabled" /><span class="hint inline">关闭后,已支付会员仍有效至到期</span></el-form-item>
|
||||||
|
<el-form-item label="会员名称" required><el-input v-model="form.name" maxlength="50" style="width:360px" /></el-form-item>
|
||||||
|
<el-form-item label="会员图标"><UploadPicInput v-model="form.icon" /><div class="hint">推荐100 × 100像素,透明PNG</div></el-form-item>
|
||||||
|
<el-form-item label="会员背景"><el-radio-group v-model="form.backgroundType"><el-radio value="COLOR">背景色</el-radio><el-radio value="IMAGE">背景图</el-radio></el-radio-group><el-color-picker v-if="form.backgroundType === 'COLOR'" v-model="form.backgroundColor" class="inline" /></el-form-item>
|
||||||
|
<el-form-item v-if="form.backgroundType === 'IMAGE'" label="背景图"><UploadPicInput v-model="form.backgroundImage" /></el-form-item>
|
||||||
|
<el-form-item label="字体颜色"><el-color-picker v-model="form.fontColor" /></el-form-item>
|
||||||
|
<el-form-item label="开通价格" required><el-input-number v-model="form.price" :min="0.01" :max="1000000" :precision="2" :controls="false" /><span class="inline">元</span></el-form-item>
|
||||||
|
<el-form-item label="会员有效期" required><el-input-number v-model="form.months" :min="1" :max="120" :precision="0" :controls="false" /><span class="inline">个月</span><div class="hint">按自然月计算;未到期续费从原到期时间顺延。</div></el-form-item>
|
||||||
|
<el-form-item label="关联客户权益"><el-select v-model="form.benefitIds" multiple filterable style="width:480px;max-width:100%" placeholder="选择权益"><el-option v-for="b in benefitOptions" :key="b.id" :label="b.benefitName" :value="String(b.id)" /></el-select></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never"><template #header>商家设置</template>
|
||||||
|
<el-form-item label="商家范围"><el-radio-group v-model="form.storeScope"><el-radio value="SELF">自营商家</el-radio><el-radio value="CUSTOM">自定义选择</el-radio></el-radio-group></el-form-item>
|
||||||
|
<el-form-item v-if="form.storeScope === 'CUSTOM'" label="选择商家"><el-button @click="openPicker('stores')">添加商家</el-button><div class="picked-tags"><el-tag v-for="id in form.storeIds" :key="id" closable @close="remove('storeIds', id)">{{ stores[id]?.storeName || id }}</el-tag></div></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never"><template #header>商品折扣</template>
|
||||||
|
<el-form-item label="商品会员折扣"><el-radio-group v-model="form.discountMode"><el-radio value="ALL">所有适用商品统一折扣</el-radio><el-radio value="CUSTOM">自定义商品折扣</el-radio></el-radio-group></el-form-item>
|
||||||
|
<el-form-item v-if="form.discountMode === 'ALL'" label="统一折扣"><el-input-number v-model="form.discount" :min="0.01" :max="10" :precision="2" :controls="false" /><span class="inline">折</span></el-form-item>
|
||||||
|
<el-form-item v-else label="折扣商品"><div class="full"><el-button @click="openPicker('discount')">添加商品</el-button><el-table :data="filteredDiscountRules" border class="mt_10"><el-table-column label="商品" min-width="230"><template #default="{row}">{{ goods[row.skuId]?.goodsName || row.skuId }}</template></el-table-column><el-table-column label="SKU编码" width="180"><template #default="{row}">{{ goods[row.skuId]?.sn || row.skuId }}</template></el-table-column><el-table-column label="规格" min-width="160" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.simpleSpecs?.trim() || '—' }}</template></el-table-column><el-table-column label="分类" min-width="180" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.categoryName || '—' }}</template></el-table-column><el-table-column label="品牌" min-width="120" show-overflow-tooltip><template #default="{row}">{{ goods[row.skuId]?.brandName || '—' }}</template></el-table-column><el-table-column label="售价" width="120" align="right"><template #default="{row}">{{ goods[row.skuId]?.price == null ? '—' : '¥' + Number(goods[row.skuId].price).toFixed(2) }}</template></el-table-column><el-table-column label="折扣" width="190"><template #default="{row}"><el-input-number v-model="row.discount" :min="0.01" :max="10" :precision="2" style="width:140px" :controls="false" /> 折</template></el-table-column><el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="danger" @click="form.goodsRules = form.goodsRules.filter(r => r.skuId !== row.skuId)">移除</el-button></template></el-table-column></el-table></div></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never"><template #header>会员中心推荐商品</template>
|
||||||
|
<el-button @click="openPicker('recommended')">添加商品</el-button>
|
||||||
|
<el-table :data="form.recommendedSkuIds.map(id => goods[id] || {id})" border class="mt_10"><el-table-column prop="sn" label="SKU编码" width="180" /><el-table-column label="商品名称" min-width="240"><template #default="{row}">{{ row.goodsName || row.id }}</template></el-table-column><el-table-column label="规格" min-width="160" show-overflow-tooltip><template #default="{row}">{{ row.simpleSpecs?.trim() || '—' }}</template></el-table-column><el-table-column label="分类" min-width="180" show-overflow-tooltip><template #default="{row}">{{ row.categoryName || '—' }}</template></el-table-column><el-table-column label="品牌" min-width="120" show-overflow-tooltip><template #default="{row}">{{ row.brandName || '—' }}</template></el-table-column><el-table-column label="售价" width="120" align="right"><template #default="{row}">{{ row.price == null ? '—' : '¥' + Number(row.price).toFixed(2) }}</template></el-table-column><el-table-column label="操作" width="90"><template #default="{row}"><el-button link type="danger" @click="remove('recommendedSkuIds', String(row.id))">移除</el-button></template></el-table-column></el-table>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>展示设置</template>
|
||||||
|
<div class="entry-settings-grid">
|
||||||
|
<section class="entry-settings-block">
|
||||||
|
<h4>未开通会员入口</h4>
|
||||||
|
<el-form-item label="入口文案" required><el-input v-model="form.unopenedText" maxlength="50" show-word-limit class="entry-text-input" /></el-form-item>
|
||||||
|
<el-form-item label="字体颜色" required><el-color-picker v-model="form.unopenedFontColor" /><span class="entry-color-value">{{ form.unopenedFontColor }}</span></el-form-item>
|
||||||
|
<el-form-item label="背景图"><UploadPicInput v-model="form.unopenedBackground" /><div class="hint">用于个人中心未开通及已到期入口,推荐 750 × 160 像素,支持 PNG/JPG;不设置时使用默认渐变背景。</div></el-form-item>
|
||||||
|
<el-form-item label="效果预览"><div class="entry-preview" :style="{color: form.unopenedFontColor}"><img v-if="form.unopenedBackground" :src="form.unopenedBackground" alt="未开通入口背景" /><div class="entry-preview-content"><div><strong>{{ form.unopenedText || '开通付费会员,享专属权益' }}</strong><small>开通享商品优惠与专属权益</small></div><span>›</span></div></div></el-form-item>
|
||||||
|
</section>
|
||||||
|
<section class="entry-settings-block">
|
||||||
|
<h4>已开通会员入口</h4>
|
||||||
|
<el-form-item label="入口文案" required><el-input v-model="form.openedText" maxlength="50" show-word-limit class="entry-text-input" /></el-form-item>
|
||||||
|
<el-form-item label="字体颜色" required><el-color-picker v-model="form.openedFontColor" /><span class="entry-color-value">{{ form.openedFontColor }}</span></el-form-item>
|
||||||
|
<el-form-item label="背景图"><UploadPicInput v-model="form.openedBackground" /><div class="hint">用于个人中心有效会员入口,推荐 750 × 160 像素,支持 PNG/JPG;不设置时使用默认渐变背景。</div></el-form-item>
|
||||||
|
<el-form-item label="效果预览"><div class="entry-preview" :style="{color: form.openedFontColor}"><img v-if="form.openedBackground" :src="form.openedBackground" alt="已开通入口背景" /><div class="entry-preview-content"><div><strong>{{ form.openedText || '我的付费会员' }}</strong><small>有效期至 2027-11-05(示例)</small></div><span>›</span></div></div></el-form-item>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="会员价格标签"><UploadPicInput v-model="form.priceLabel" /><div class="hint">展示在商品列表、商品详情的会员价前,推荐 48 × 48 像素、透明 PNG;不设置时只显示会员价文字。</div></el-form-item>
|
||||||
|
<el-form-item label="标签预览"><div class="price-label-preview"><img v-if="form.priceLabel" :src="form.priceLabel" alt="会员价格标签" /><span>会员价 ¥88.00</span></div></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="document-card">
|
||||||
|
<template #header><div class="document-heading"><div><span>购买说明</span><div class="document-hint">展示在移动端付费会员页面,可填写权益介绍和使用说明。</div></div><el-button @click="fullscreenEditor(descriptionEditor)">全屏编辑</el-button></div></template>
|
||||||
|
<el-form-item label-width="0" class="document-field"><RichEditor ref="descriptionEditor" v-model="form.description" image-picker height="520px" class="full" /></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="document-card">
|
||||||
|
<template #header><div class="document-heading"><div><span>会员协议</span><div class="document-hint">用户购买前阅读并同意的协议,开启购买前请完善内容。</div></div><el-button @click="fullscreenEditor(agreementEditor)">全屏编辑</el-button></div></template>
|
||||||
|
<el-form-item label-width="0" class="document-field"><RichEditor ref="agreementEditor" v-model="form.agreement" image-picker height="600px" class="full" /></el-form-item>
|
||||||
|
</el-card>
|
||||||
|
<div class="save-bar"><el-button type="primary" :loading="saving" :disabled="loading || !loaded" @click="save">保存设置</el-button></div>
|
||||||
|
</el-form>
|
||||||
|
<StoreSelector ref="picker" @selected="selected" />
|
||||||
|
<GoodsSelector ref="goodsPicker" :goods-loader="discountPicking ? getPaidDiscountSkus : undefined" :query-params="discountQuery" @selectedGoodsData="selected" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, onMounted, watch, computed } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import UploadPicInput from '@/components/lili/upload-pic-input.vue';
|
||||||
|
import RichEditor from '@/components/editor/index.vue';
|
||||||
|
import StoreSelector from '@/components/store-selector/index.vue';
|
||||||
|
import GoodsSelector from '@/components/lili-dialog/index.vue';
|
||||||
|
import { getPaidConfig, savePaidConfig, getPaidResources, getPaidDiscountSkus } from '@/api/paid-member';
|
||||||
|
import { getMemberBenefitByPage } from '@/api/member';
|
||||||
|
defineOptions({name:'paidMemberSettings'});
|
||||||
|
const form = reactive({ goodsRules: [], storeIds: [], recommendedSkuIds: [], benefitIds: [] });
|
||||||
|
const loading = ref(false), loaded = ref(false), loadError = ref(false), saving = ref(false), picker = ref(), benefitOptions = ref([]), stores = reactive({}), goods = reactive({});
|
||||||
|
const descriptionEditor = ref(), agreementEditor = ref(), goodsPicker = ref(), discountPicking = ref(false);
|
||||||
|
const discountQuery = computed(() => discountPicking.value ? { storeScope: form.storeScope, storeIds: form.storeIds.join(',') } : {});
|
||||||
|
const filteredDiscountRules = computed(() => form.goodsRules.filter(rule => storeAllowed(goods[rule.skuId])));
|
||||||
|
function fullscreenEditor(editor) { editor?.getEditor()?.execCommand('mceFullScreen'); }
|
||||||
|
let target = '';
|
||||||
|
function remember(rows, type) { rows.forEach(row => { const cache = type === 'STORE' ? stores : goods; cache[String(row.id)] = { ...cache[String(row.id)], ...row }; }); }
|
||||||
|
function remove(field, id) { form[field] = form[field].filter(value => value !== id); }
|
||||||
|
async function openPicker(value) {
|
||||||
|
target = value;
|
||||||
|
if (value === 'stores') {
|
||||||
|
picker.value.open(form.storeIds.map(id => ({ ...stores[id], id: String(id) })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discountPicking.value = value === 'discount';
|
||||||
|
if (discountPicking.value) {
|
||||||
|
if (form.storeScope === 'CUSTOM' && !form.storeIds.length) { ElMessage.warning('请先选择参与商家'); return; }
|
||||||
|
try { await reconcileDiscounts(); } catch { ElMessage.error('商品商家信息加载失败,请重试'); return; }
|
||||||
|
}
|
||||||
|
const ids = value === 'discount' ? form.goodsRules.map(rule => rule.skuId) : form.recommendedSkuIds;
|
||||||
|
goodsPicker.value.goodsData = ids.map(id => ({ ...goods[id], id: String(id) }));
|
||||||
|
goodsPicker.value.open('goods');
|
||||||
|
}
|
||||||
|
async function selected(rows) {
|
||||||
|
remember(rows, target === 'stores' ? 'STORE' : 'SKU');
|
||||||
|
if (target === 'stores') { form.storeIds = rows.map(row => String(row.id)); return; }
|
||||||
|
if (target === 'discount') {
|
||||||
|
try { await hydrate('SKU', rows.map(row => String(row.id))); } catch { ElMessage.error('商品商家信息加载失败,请重试'); return; }
|
||||||
|
const validRows = rows.filter(row => storeAllowed(goods[String(row.id)]));
|
||||||
|
if (validRows.length !== rows.length) ElMessage.warning('已排除不属于参与商家的商品');
|
||||||
|
const discounts = new Map(form.goodsRules.map(rule => [String(rule.skuId), rule.discount]));
|
||||||
|
form.goodsRules = validRows.map(row => ({ skuId: String(row.id), discount: discounts.get(String(row.id)) ?? form.discount ?? 9.5 }));
|
||||||
|
} else { form.recommendedSkuIds = rows.map(row => String(row.id)); }
|
||||||
|
const missing = rows.filter(row => !Object.hasOwn(row, 'categoryName') || !Object.hasOwn(row, 'brandName'));
|
||||||
|
if (missing.length) { try { await hydrate('SKU', missing.map(row => String(row.id))); } catch { ElMessage.warning('商品分类和品牌加载失败,可刷新页面重试'); } }
|
||||||
|
}
|
||||||
|
async function save() { saving.value = true; try { await reconcileDiscounts(); const res = await savePaidConfig({...form}); if (res.success) ElMessage.success('付费会员设置已保存'); } finally { saving.value = false; } }
|
||||||
|
function storeAllowed(sku) {
|
||||||
|
return !!sku && (form.storeScope === 'SELF' ? sku.storeSelfOperated === true : form.storeIds.map(String).includes(String(sku.storeId)));
|
||||||
|
}
|
||||||
|
async function reconcileDiscounts() {
|
||||||
|
const missing = form.goodsRules.filter(rule => !Object.hasOwn(goods[rule.skuId] || {}, 'storeSelfOperated'));
|
||||||
|
if (missing.length) await hydrate('SKU', missing.map(rule => rule.skuId));
|
||||||
|
const remaining = form.goodsRules.filter(rule => storeAllowed(goods[rule.skuId]));
|
||||||
|
if (remaining.length !== form.goodsRules.length) { form.goodsRules = remaining; ElMessage.warning('已移除不属于当前参与商家的折扣商品'); }
|
||||||
|
}
|
||||||
|
watch(() => [form.storeScope, ...form.storeIds], () => {
|
||||||
|
if (loading.value || !loaded.value) return;
|
||||||
|
reconcileDiscounts().catch(() => ElMessage.error('商品商家信息加载失败,请重试'));
|
||||||
|
}, { flush: 'sync' });
|
||||||
|
async function hydrate(type, ids) {
|
||||||
|
for (let i=0; i<ids.length; i+=100) { const res = await getPaidResources(type, ids.slice(i,i+100)); if (!res.success) throw new Error('resources'); remember(res.result, type); }
|
||||||
|
}
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true; loaded.value = false; loadError.value = false;
|
||||||
|
try {
|
||||||
|
const [config, rights] = await Promise.all([getPaidConfig(), getMemberBenefitByPage({pageNumber:1,pageSize:200})]);
|
||||||
|
if (!config.success) { loadError.value = true; return; }
|
||||||
|
Object.assign(form, config.result); loaded.value = true; benefitOptions.value = rights.result?.records || [];
|
||||||
|
const ids = [...new Set([...form.recommendedSkuIds, ...form.goodsRules.map(r => r.skuId)])];
|
||||||
|
await Promise.all([hydrate('SKU',ids), hydrate('STORE',form.storeIds)]);
|
||||||
|
} catch { loadError.value = true; } finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
onMounted(loadSettings);
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.entry-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
|
||||||
|
.entry-settings-block { min-width: 0; border: 1px solid #ebeef5; border-radius: 6px; padding: 20px 16px 0; }
|
||||||
|
.entry-settings-block h4 { margin: 0 0 24px; color: #303133; font-size: 14px; }
|
||||||
|
.entry-text-input { width: 480px; max-width: 100%; }
|
||||||
|
.entry-color-value { margin-left: 12px; color: #909399; font-size: 12px; }
|
||||||
|
.entry-preview { position: relative; overflow: hidden; width: 375px; max-width: 100%; min-height: 80px; border-radius: 10px; background: linear-gradient(110deg, #423c36, #756149); }
|
||||||
|
.entry-preview > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.entry-preview-content { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px; min-height: 80px; box-sizing: border-box; line-height: 1.5; }
|
||||||
|
.entry-preview-content strong { font-size: 14px; }.entry-preview-content small { display: block; margin-top: 4px; opacity: .75; font-size: 11px; }.entry-preview-content > span { font-size: 24px; }
|
||||||
|
.price-label-preview { display: flex; align-items: center; gap: 6px; color: #a77c3e; font-size: 13px; }.price-label-preview img { width: 24px; height: 24px; object-fit: contain; }
|
||||||
|
@media (max-width: 1400px) { .entry-settings-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.document-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
|
.document-heading > div { min-width: 0; }
|
||||||
|
.document-hint { margin-top: 8px; color: #909399; font-size: 12px; font-weight: normal; line-height: 20px; }
|
||||||
|
.document-field { margin-bottom: 0; }
|
||||||
|
.document-field :deep(.el-form-item__content) { min-width: 0; }
|
||||||
|
.document-card :deep(.tox-tinymce) { border-color: #dcdfe6; }
|
||||||
|
.paid-settings { padding-bottom: 70px; }.el-card { margin-top: 16px; }.hint { width: 100%; font-size: 12px; line-height: 20px; margin-top: 6px; color: #909399; }.inline { margin-left: 12px; }.hint.inline { width: auto; margin-top: 0; }.full { width: 100%; min-width: 0; }.picked-tags { width: 100%; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }.save-bar { position: sticky; bottom: 0; padding: 16px; background: #fff; border-top: 1px solid #ebeef5; display: flex; justify-content: flex-end; z-index: 10; }
|
||||||
|
</style>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<div class="ctrl-row">
|
<div class="ctrl-row">
|
||||||
<div class="ctrl-label">选择店铺</div>
|
<div class="ctrl-label">选择店铺</div>
|
||||||
<div class="ctrl-body">
|
<div class="ctrl-body">
|
||||||
<el-button plain type="primary" @click="shopDialogVisible = true">选择店铺</el-button>
|
<el-button plain type="primary" @click="openShops">选择店铺</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-chips" v-if="firstItem.shopList && firstItem.shopList.length">
|
<div class="shop-chips" v-if="firstItem.shopList && firstItem.shopList.length">
|
||||||
@@ -176,38 +176,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<StoreSelector ref="storeSelector" :query-params="{ storeDisable: 'OPEN' }" @selected="confirmShops" />
|
||||||
v-model="shopDialogVisible"
|
|
||||||
title="选择店铺"
|
|
||||||
width="720px"
|
|
||||||
append-to-body
|
|
||||||
destroy-on-close
|
|
||||||
@open="loadShops"
|
|
||||||
>
|
|
||||||
<el-table
|
|
||||||
:data="shopOptions"
|
|
||||||
v-loading="shopLoading"
|
|
||||||
max-height="400"
|
|
||||||
@selection-change="onShopSelection"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="50" />
|
|
||||||
<el-table-column label="店铺" min-width="220">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="shop-cell">
|
|
||||||
<img :src="row.storeLogo" class="shop-cell-logo" alt="" />
|
|
||||||
<span>{{ row.storeName }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="类型" width="100">
|
|
||||||
<template #default="{ row }">{{ row.selfOperated ? "自营" : "旗舰店" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="shopDialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="confirmShops">确定</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -217,7 +186,7 @@ import ColorField from "../controls/ColorField.vue";
|
|||||||
import SliderField from "../controls/SliderField.vue";
|
import SliderField from "../controls/SliderField.vue";
|
||||||
import ossManage from "@/views/sys/oss-manage/ossManage";
|
import ossManage from "@/views/sys/oss-manage/ossManage";
|
||||||
import ways from "@/components/lili-dialog/wap.js";
|
import ways from "@/components/lili-dialog/wap.js";
|
||||||
import { getShopListData } from "@/api/shops";
|
import StoreSelector from "@/components/store-selector/index.vue";
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
bgColor1: "#FFFFFF",
|
bgColor1: "#FFFFFF",
|
||||||
@@ -247,7 +216,7 @@ const DEFAULT_ITEM = {
|
|||||||
export default {
|
export default {
|
||||||
name: "PanelShop",
|
name: "PanelShop",
|
||||||
hideDecorateTitle: true,
|
hideDecorateTitle: true,
|
||||||
components: { GradientField, ColorField, SliderField, ossManage },
|
components: { GradientField, ColorField, SliderField, ossManage, StoreSelector },
|
||||||
props: {
|
props: {
|
||||||
res: { type: Object, required: true },
|
res: { type: Object, required: true },
|
||||||
},
|
},
|
||||||
@@ -260,10 +229,6 @@ export default {
|
|||||||
{ key: "style", title: "样式" },
|
{ key: "style", title: "样式" },
|
||||||
],
|
],
|
||||||
picModelFlag: false,
|
picModelFlag: false,
|
||||||
shopDialogVisible: false,
|
|
||||||
shopLoading: false,
|
|
||||||
shopOptions: [],
|
|
||||||
selectedShops: [],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -284,30 +249,15 @@ export default {
|
|||||||
if (!Array.isArray(item.shopList)) item.shopList = [];
|
if (!Array.isArray(item.shopList)) item.shopList = [];
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 加载店铺列表供选择
|
openShops() { this.$refs.storeSelector.open(this.firstItem.shopList); },
|
||||||
loadShops() {
|
confirmShops(rows) {
|
||||||
this.shopLoading = true;
|
this.firstItem.shopList = rows.map((s) => ({
|
||||||
this.selectedShops = [];
|
|
||||||
getShopListData({ pageNumber: 1, pageSize: 100, status: "OPEN" })
|
|
||||||
.then((res) => {
|
|
||||||
this.shopOptions = (res && res.result && res.result.records) || [];
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.shopLoading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onShopSelection(rows) {
|
|
||||||
this.selectedShops = rows;
|
|
||||||
},
|
|
||||||
confirmShops() {
|
|
||||||
this.firstItem.shopList = this.selectedShops.map((s) => ({
|
|
||||||
id: s.id,
|
id: s.id,
|
||||||
storeName: s.storeName,
|
storeName: s.storeName,
|
||||||
storeLogo: s.storeLogo,
|
storeLogo: s.storeLogo,
|
||||||
selfOperated: s.selfOperated,
|
selfOperated: s.selfOperated,
|
||||||
storeBanner: s.storeBanner || "",
|
storeBanner: s.storeBanner || "",
|
||||||
}));
|
}));
|
||||||
this.shopDialogVisible = false;
|
|
||||||
},
|
},
|
||||||
removeShop(index) {
|
removeShop(index) {
|
||||||
this.firstItem.shopList.splice(index, 1);
|
this.firstItem.shopList.splice(index, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user