购物车页面是电子商务网站或应用程序中的一个关键功能页面,它允许用户查看、编辑和管理他们选择加入购物车的商品。下面通过低代码可视化实现一个uniapp购物车页面,把购物车整个事件都集成进去。实现完成后可以保存为页面模板。

收货地址选择

如果尚未选择收货地址,用户可以在此选择或输入新收货地址。

API加载收货地址

api加载当前用户所有收货地址,如果没有收货地址,提示先新增地址。

如果有地址,支持显示用户收货地址。

商品列表

循环显示用户购物车的数据。

商品图片:显示商品的缩略图,帮助用户快速识别。

商品名称:商品的完整名称或描述。

价格:每个商品的单价,以及可能的折扣价或原价对比。

数量选择器:允许用户增加或减少所选商品的数量。
 



总价计算

商品总价:所有商品单价乘以数量的总和。。
订单总价:商品总价加上税运费(如果有)的最终金额。

查看源码

点击查看源码,也可以设计器上显示源码。

保存源码至本地

保存源码至本地,即可看见购物车功能。

<template>
	<view class="container container329916">
		<view class="flex flex-wrap diygw-col-24 flex-direction-column">
			<view v-if="userInfo.token && userInfo.carts && userInfo.carts.length > 0 && globalData.userAddress.id" class="flex flex-wrap diygw-col-24 items-stretch flex7-clz">
				<view class="flex flex-wrap diygw-col-0 flex-direction-column justify-between items-center flex6-clz">
					<view class="flex flex-wrap diygw-col-24 items-center">
						<text class="diygw-col-0 text5-clz"> 收货人:{{ globalData.userAddress.title }} </text>
						<text class="diygw-col-0 text6-clz">
							{{ globalData.userAddress.phone }}
						</text>
					</view>
					<text class="diygw-text-line1 diygw-col-24 text30-clz"> {{ globalData.userAddress.provinceLabel }}{{ globalData.userAddress.address }} </text>
				</view>
				<text class="diygw-col-0 text8-clz"> </text>
				<view class="flex flex-wrap diygw-col-0 items-center flex11-clz" @tap="navigateTo" data-type="page" data-url="/pages/address">
					<text class="diygw-col-0"> 修改默认地址 </text>
					<text class="flex icon4 diygw-col-0 diy-icon-right"></text>
				</view>
			</view>
			<view v-if="userInfo.token && userInfo.carts && userInfo.carts.length > 0 && !globalData.userAddress.id" class="flex flex-wrap diygw-col-24 items-stretch flex13-clz" @tap="navigateTo" data-type="page" data-url="/pages/address">
				<view class="flex flex-wrap diygw-col-0 flex-direction-column justify-between items-center flex14-clz">
					<view class="flex flex-wrap diygw-col-24 items-center">
						<text class="diygw-col-0 text12-clz"> 未找到收货地址,请先维维护 </text>
					</view>
				</view>
				<text class="diygw-col-0 text15-clz"> </text>
				<view class="flex flex-wrap diygw-col-0 items-center flex16-clz">
					<text class="diygw-col-0"> 新增地址 </text>
					<text class="flex icon5 diygw-col-0 diy-icon-right"></text>
				</view>
			</view>
			<view v-for="(item, index) in userInfo.carts" :key="index" class="flex flex-wrap diygw-col-24 items-center flex5-clz">
				<text v-if="item.selected == 1" @tap="navigateTo" data-type="selectOneFunction" :data-index="index" class="flex icon diygw-col-0 icon-clz diy-icon-roundcheck"></text>
				<text v-else @tap="navigateTo" data-type="selectOneFunction" :data-index="index" class="flex icon1 diygw-col-0 icon1-clz diy-icon-round"></text>
				<view class="flex flex-wrap diygw-col-0 items-stretch flex4-clz">
					<image :src="item.img" class="image-size diygw-image diygw-col-0 image-clz" mode="scaleToFill"></image>
					<view class="flex flex-wrap diygw-col-0 flex-direction-column justify-between flex2-clz">
						<text class="diygw-text-line2 diygw-col-24 text-clz">
							{{ item.title }}
						</text>
						<view class="flex flex-wrap diygw-col-24 justify-between items-center">
							<text class="diygw-text-line2 diygw-col-0 text2-clz"> {{ item.price }}元 </text>
							<u-form-item :borderBottom="false" class="diygw-col-0 diygw-form-item-notpadding" labelPosition="top" prop="number">
								<view class="flex diygw-col-24">
									<u-number-box :inputHeight="48" @change="changeItemNumber($event, index, item)" name="number" v-model="item.number" bgColor="#EBECEE" color="#323233" :min="1" :max="100" :step="1" />
								</view>
							</u-form-item>
						</view>
					</view>
				</view>
			</view>
		</view>
		<view v-if="!userInfo.carts || (userInfo.carts && userInfo.carts.length == 0)" class="flex flex-wrap diygw-col-24 flex-direction-column items-center flex10-clz">
			<image src="/static/zwjl.png" class="image1-size diygw-image diygw-col-0" mode="widthFix"></image>
			<text class="diygw-col-0 text7-clz"> 您的购物车是空的,快去逛逛吧 </text>
			<text @tap="navigateTo" data-type="page" data-url="/pages/goods" class="diygw-col-0 text13-clz"> 去逛逛 </text>
		</view>
		<view class="flex flex-wrap diygw-col-24 flex-direction-column items-center diygw-bottom flex28-clz">
			<view v-if="userInfo.token && userInfo.carts && userInfo.carts.length > 0" class="flex diygw-col-24 justify-between items-center flex-nowrap flex-clz">
				<view class="flex flex-wrap diygw-col-0 items-center" @tap="navigateTo" data-type="selectAllFunction">
					<text v-if="globalData.totalSelected == '1'" class="flex icon2 diygw-col-0 icon2-clz diy-icon-roundcheck"></text>
					<text v-if="globalData.totalSelected != '1'" class="flex icon3 diygw-col-0 icon3-clz diy-icon-round"></text>
					<text class="diygw-col-0"> 合计: </text>
					<text class="diygw-col-0 text3-clz"> {{ globalData.totalPrice }}元 </text>
				</view>
				<text v-if="userInfo.token" @tap="navigateTo" data-type="orderApi" class="diygw-col-0 text4-clz"> 立即购买 </text>
				<text v-else @tap="navigateTo" data-type="page" data-url="/pages/login" class="diygw-col-0 text10-clz"> 还未登录,立即登录 </text>
			</view>
			<view class="flex flex-wrap diygw-col-24 items-end flex17-clz">
				<view class="flex flex-wrap diygw-col-6 flex-direction-column items-center flex18-clz" @tap="navigateTo" data-type="page" data-url="/pages/index" data-redirect="1">
					<view class="flex flex-wrap diygw-col-0 flex-direction-column items-center">
						<image src="/static/sy3.png" class="image2-size diygw-image diygw-col-0" mode="widthFix"></image>
					</view>
					<text class="diygw-text-line1 diygw-col-0"> 首页 </text>
				</view>
				<view class="flex flex-wrap diygw-col-6 flex-direction-column items-center flex20-clz" @tap="navigateTo" data-type="page" data-url="/pages/goods" data-redirect="1">
					<view class="flex flex-wrap diygw-col-0 flex-direction-column items-center">
						<image src="/static/fl.png" class="image8-size diygw-image diygw-col-0" mode="widthFix"></image>
					</view>
					<text class="diygw-text-line1 diygw-col-0"> 分类 </text>
				</view>
				<view class="flex flex-wrap diygw-col-6 flex-direction-column items-center flex21-clz" @tap="navigateTo" data-type="page" data-url="/pages/cart" data-redirect="1">
					<view class="flex flex-wrap diygw-col-0 flex-direction-column items-center">
						<text v-if="userInfo.carts && userInfo.carts.length > 0" class="diygw-text-line1 diygw-col-0 animate__animated animate__heartBeat animate__infinite text19-clz"> </text>
						<image src="/static/gwcon.png" class="image5-size diygw-image diygw-col-0" mode="widthFix"></image>
					</view>
					<text class="diygw-text-line1 diygw-col-0"> 购物车 </text>
				</view>
				<view class="flex flex-wrap diygw-col-6 flex-direction-column items-center flex23-clz" @tap="navigateTo" data-type="page" data-url="/pages/articles" data-redirect="1">
					<view class="flex flex-wrap diygw-col-0 flex-direction-column items-center">
						<image src="/static/cp1.png" class="image3-size diygw-image diygw-col-0" mode="widthFix"></image>
					</view>
					<text class="diygw-text-line1 diygw-col-0"> 文章 </text>
				</view>
				<view class="flex flex-wrap diygw-col-6 flex-direction-column items-center flex26-clz" @tap="navigateTo" data-type="page" data-url="/pages/user" data-redirect="1">
					<view class="flex flex-wrap diygw-col-0 flex-direction-column items-center">
						<image src="/static/wd.png" class="image4-size diygw-image diygw-col-0" mode="widthFix"></image>
					</view>
					<text class="diygw-text-line1 diygw-col-0"> 我的 </text>
				</view>
			</view>
		</view>
		<view class="clearfix"></view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				//用户全局信息
				userInfo: {},
				//页面传参
				globalOption: {},
				//自定义全局变量
				globalData: { userAddress: {}, totalPrice: 0, totalSelected: '0' },
				addressNum: 1,
				address: {
					rows: [
						{
							id: 0,
							title: '',
							phone: null,
							isdefault: null,
							remark: null,
							sortnum: null,
							status: '',
							province: null,
							address: null,
							userId: 0,
							createTime: '',
							updateTime: '',
							deleteTime: null
						}
					],
					total: 0,
					code: 0,
					msg: ''
				},
				orderNum: 1,
				order: {
					code: 0,
					msg: ''
				},
				item: {
					number: 1
				}
			};
		},
		computed: {},
		onShow() {
			this.setCurrentPage(this);

			this.initShow();
		},
		onLoad(option) {
			this.setCurrentPage(this);
			if (option) {
				this.setData({
					globalOption: this.getOption(option)
				});
			}

			this.init();
		},
		methods: {
			async init() {},
			async initShow() {
				//强制重新刷新页面
				this.addressApi({ refresh: 1 });
				await this.initCartFunction();
			},
			// 用户地址 API请求方法
			async addressApi(param) {
				let thiz = this;
				param = param || {};

				//请求地址及请求数据,可以在加载前执行上面增加自己的代码逻辑
				let http_url = '/shop/address/list';
				let http_data = {
					pageNum: this.addressNum,
					pageSize: 10,
					isself: param.isself || '1'
				};
				let http_header = {};

				//如果用户未登录,不加载用户数据
				if (!this.userInfo.token) {
					return;
				}

				let address = await this.$http.post(http_url, http_data, http_header, 'json');

				this.address = address;
				let find = address.rows.find((item) => {
					return item.isdefault == 1;
				});
				if (find) {
					this.globalData.userAddress = find;
				} else if (address.rows.length > 0) {
					this.globalData.userAddress = address.rows[0];
				}
			},
			// 保存订单 API请求方法
			async orderApi(param) {
				let thiz = this;
				param = param || {};

				//如果请求要重置页面,请配置点击附加参数refresh=1  增加判断如输入框回调param不是对象
				if (param.refresh || typeof param != 'object') {
					this.orderNum = 1;
				}

				//请求地址及请求数据,可以在加载前执行上面增加自己的代码逻辑
				let http_url = '/shop/order/add';
				let http_data = {
					pageNum: this.orderNum,
					pageSize: 10
				};
				let http_header = {
					'Content-Type': 'application/json'
				};

				let carts = this.userInfo.carts || [];
				let body = carts.filter((item) => {
					return item.selected == 1;
				});
				if (!this.globalData.userAddress.id) {
					this.showToast('请设置收货地址');
					return;
				}
				if (body.length == 0) {
					this.showToast('请选择产品');
					return;
				}
				let title = body
					.map((item) => {
						return item.title;
					})
					.join(';');
				http_data.title = title;
				http_data.total = this.globalData.totalPrice;
				http_data.openid = this.userInfo.openid;
				http_data.body = { carts: body, address: this.globalData.userAddress };

				let order = await this.$http.post(http_url, http_data, http_header, 'json');

				if (order.code != 200) {
					this.showToast(order.msg);
					return;
				}
				carts = carts.filter((item) => {
					return item.selected != 1;
				});
				this.$session.setUserValue('carts', carts);
				//跳转至订单详情页面
				this.navigateTo({
					type: 'page',
					url: 'order/detail',
					id: order.data.id
				});

				let datarows = order.rows;
				if (http_data.pageNum == 1) {
					this.order = order;
				} else if (datarows) {
					let rows = this.order.rows.concat(datarows);
					order.rows = rows;
					this.order = order;
				}
				if (datarows && datarows.length > 0) {
					this.orderNum = this.orderNum + 1;
				}
				this.globalData.isshow = true;
			},

			// 计算总价 自定义方法
			async totalPriceFunction(param) {
				let thiz = this;
				let total = 0;
				let checked = 1;
				let carts = this.userInfo.carts || [];
				carts.forEach((item) => {
					if (item.selected == 1) {
						total = total + item.price * item.number;
					} else {
						checked = 0;
					}
				});
				this.globalData.totalPrice = Number(total.toFixed(2));
				this.globalData.totalSelected = checked;
				this.$session.setUserValue('carts', carts);
			},

			// 选择全部或取消选择 自定义方法
			async selectAllFunction(param) {
				let thiz = this;
				this.globalData.totalSelected = this.globalData.totalSelected == '1' ? '0' : '1';
				//设置选中或取消
				let carts = this.userInfo.carts || [];
				carts.forEach((item) => {
					item.selected = this.globalData.totalSelected;
				});
				//计算总价
				this.totalPriceFunction();
			},

			// 选择或取消选择 自定义方法
			async selectOneFunction(param) {
				let thiz = this;
				let index = param && (param.index || param.index == 0) ? param.index : thiz.index || '';
				//选中或者取消
				let carts = this.userInfo.carts || [];
				carts[param.index].selected = carts[param.index].selected == '1' ? '0' : '1';
				//计算总价
				this.totalPriceFunction();
			},

			// 初始计算 自定义方法
			async initCartFunction(param) {
				let thiz = this;
				let carts = this.userInfo.carts || [];
				carts.forEach((item) => {
					item.selected = 1;
				});
				this.userInfo.carts = carts;
				//计算总价
				this.totalPriceFunction();
			},
			changeItemNumber(evt, index, item) {
				this.navigateTo({ foritem: item, forindex: index, type: 'totalPriceFunction' });
			}
		},
		onPullDownRefresh() {
			// 保存订单 API请求方法
			this.orderNum = 1;
			this.orderApi();

			uni.stopPullDownRefresh();
		},
		onReachBottom() {
			// 保存订单 API请求方法
			this.orderApi();
		}
	};
</script>

<style lang="scss" scoped>
	.flex7-clz {
		padding-top: 20rpx;
		border-bottom-left-radius: 24rpx;
		padding-left: 20rpx;
		padding-bottom: 20rpx;
		border-top-right-radius: 24rpx;
		margin-right: 20rpx;
		background-color: #ffffff;
		margin-left: 20rpx;
		overflow: hidden;
		width: calc(100% - 20rpx - 20rpx) !important;
		border-top-left-radius: 24rpx;
		margin-top: 20rpx;
		border-bottom-right-radius: 24rpx;
		margin-bottom: 20rpx;
		padding-right: 20rpx;
	}
	.flex6-clz {
		flex: 1;
	}
	.text5-clz {
		flex: 1;
		font-size: 28rpx !important;
	}
	.text6-clz {
		font-weight: bold;
		font-size: 28rpx !important;
	}
	.text30-clz {
		margin-left: 0rpx;
		color: #989898;
		flex: 1;
		width: calc(100% - 0rpx - 0rpx) !important;
		margin-top: 10rpx;
		margin-bottom: 0rpx;
		margin-right: 0rpx;
	}
	.text8-clz {
		margin-left: 16rpx;
		flex-shrink: 0;
		width: 2rpx !important;
		margin-top: 10rpx;
		background-image: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.2932422969187676) 50%, rgba(255, 255, 255, 0) 100%);
		margin-bottom: 10rpx;
		margin-right: 16rpx;
	}
	.flex11-clz {
		color: #808080;
	}
	.icon4 {
		font-size: 24rpx;
	}
	.flex13-clz {
		padding-top: 20rpx;
		border-bottom-left-radius: 24rpx;
		padding-left: 20rpx;
		padding-bottom: 20rpx;
		border-top-right-radius: 24rpx;
		margin-right: 20rpx;
		background-color: #ffffff;
		margin-left: 20rpx;
		overflow: hidden;
		width: calc(100% - 20rpx - 20rpx) !important;
		border-top-left-radius: 24rpx;
		margin-top: 20rpx;
		border-bottom-right-radius: 24rpx;
		margin-bottom: 20rpx;
		padding-right: 20rpx;
	}
	.flex14-clz {
		flex: 1;
	}
	.text12-clz {
		color: #989898;
		flex: 1;
	}
	.text15-clz {
		margin-left: 16rpx;
		flex-shrink: 0;
		width: 2rpx !important;
		margin-top: 10rpx;
		background-image: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(135, 194, 250, 0.7932422969187676) 50%, rgba(255, 255, 255, 0) 100%);
		margin-bottom: 10rpx;
		margin-right: 16rpx;
	}
	.flex16-clz {
		color: #808080;
	}
	.icon5 {
		font-size: 24rpx;
	}
	.flex5-clz {
		padding-top: 20rpx;
		border-bottom-left-radius: 24rpx;
		padding-left: 20rpx;
		padding-bottom: 20rpx;
		border-top-right-radius: 24rpx;
		margin-right: 20rpx;
		background-color: #ffffff;
		margin-left: 20rpx;
		overflow: hidden;
		width: calc(100% - 20rpx - 20rpx) !important;
		border-top-left-radius: 24rpx;
		margin-top: 10rpx;
		border-bottom-right-radius: 24rpx;
		margin-bottom: 10rpx;
		padding-right: 20rpx;
	}
	.icon-clz {
		margin-left: 10rpx;
		color: #ff2a2a;
		margin-top: 0rpx;
		margin-bottom: 0rpx;
		margin-right: 10rpx;
	}
	.icon {
		font-size: 48rpx;
	}
	.icon1-clz {
		margin-left: 10rpx;
		color: #ff2a2a;
		margin-top: 0rpx;
		margin-bottom: 0rpx;
		margin-right: 10rpx;
	}
	.icon1 {
		font-size: 48rpx;
	}
	.flex4-clz {
		flex: 1;
	}
	.image-clz {
		border: 2rpx solid #eee;
		border-bottom-left-radius: 12rpx;
		text-shadow: 1px 1px 2px #333;
		overflow: hidden;
		border-top-left-radius: 12rpx;
		border-top-right-radius: 12rpx;
		border-bottom-right-radius: 12rpx;
	}
	.image-size {
		height: 160rpx !important;
		width: 160rpx !important;
	}
	.flex2-clz {
		padding-top: 0rpx;
		flex: 1;
		padding-left: 10rpx;
		padding-bottom: 0rpx;
		padding-right: 0rpx;
	}
	.text-clz {
		font-weight: bold;
		font-size: 28rpx !important;
	}
	.text2-clz {
		padding-top: 6rpx;
		color: #ff2a2a;
		font-weight: bold;
		padding-left: 0rpx;
		font-size: 28rpx !important;
		padding-bottom: 6rpx;
		padding-right: 0rpx;
	}
	.flex10-clz {
		padding-top: 20rpx;
		padding-left: 20rpx;
		padding-bottom: 20rpx;
		padding-right: 20rpx;
	}
	.image1-size {
		height: 400rpx !important;
		width: 400rpx !important;
	}
	.text7-clz {
		margin-left: 10rpx;
		color: #969696;
		font-size: 28rpx !important;
		margin-top: 10rpx;
		margin-bottom: 20rpx;
		margin-right: 10rpx;
	}
	.text13-clz {
		padding-top: 16rpx;
		border-bottom-left-radius: 200rpx;
		color: #ffffff;
		font-weight: bold;
		padding-left: 50rpx;
		font-size: 28rpx !important;
		padding-bottom: 16rpx;
		border-top-right-radius: 200rpx;
		margin-right: 10rpx;
		margin-left: 10rpx;
		overflow: hidden;
		border-top-left-radius: 200rpx;
		margin-top: 0rpx;
		border-bottom-right-radius: 200rpx;
		background-image: linear-gradient(to right, #fa2209, #ff6335);
		margin-bottom: 0rpx;
		padding-right: 50rpx;
	}
	.flex28-clz {
		background-color: #ffffff;
		border-top: 2rpx solid #e4e4e4;
		border-bottom-left-radius: 0rpx;
		box-shadow: 0rpx 4rpx 12rpx rgba(31, 31, 31, 0.16);
		overflow: visible;
		left: 0rpx;
		bottom: 0rpx;
		border-top-left-radius: 24rpx;
		border-top-right-radius: 24rpx;
		border-bottom-right-radius: 0rpx;
	}
	.flex-clz {
		padding-top: 16rpx;
		padding-left: 16rpx;
		padding-bottom: 16rpx;
		border-bottom: 2rpx solid rgba(244, 242, 242, 0.66);
		padding-right: 16rpx;
	}
	.icon2-clz {
		color: #ff2a2a;
	}
	.icon2 {
		font-size: 48rpx;
	}
	.icon3-clz {
		color: #ff2a2a;
	}
	.icon3 {
		font-size: 48rpx;
	}
	.text3-clz {
		padding-top: 6rpx;
		font-weight: bold;
		padding-left: 0rpx;
		font-size: 28rpx !important;
		padding-bottom: 6rpx;
		padding-right: 0rpx;
	}
	.text4-clz {
		padding-top: 12rpx;
		border-bottom-left-radius: 200rpx;
		color: #ffffff;
		font-weight: bold;
		padding-left: 30rpx;
		font-size: 28rpx !important;
		padding-bottom: 12rpx;
		border-top-right-radius: 200rpx;
		margin-right: 10rpx;
		margin-left: 10rpx;
		overflow: hidden;
		border-top-left-radius: 200rpx;
		margin-top: 0rpx;
		border-bottom-right-radius: 200rpx;
		background-image: linear-gradient(to right, #fa2209, #ff6335);
		margin-bottom: 0rpx;
		padding-right: 30rpx;
	}
	.text10-clz {
		padding-top: 16rpx;
		border-bottom-left-radius: 200rpx;
		color: #ffffff;
		font-weight: bold;
		padding-left: 30rpx;
		font-size: 28rpx !important;
		padding-bottom: 16rpx;
		border-top-right-radius: 200rpx;
		margin-right: 10rpx;
		margin-left: 10rpx;
		overflow: hidden;
		border-top-left-radius: 200rpx;
		margin-top: 0rpx;
		border-bottom-right-radius: 200rpx;
		background-image: linear-gradient(to right, #fa2209, #ff6335);
		margin-bottom: 0rpx;
		padding-right: 30rpx;
	}
	.flex17-clz {
		padding-top: 16rpx;
		padding-left: 16rpx;
		padding-bottom: 16rpx;
		padding-right: 16rpx;
	}
	.flex18-clz {
		flex: 1;
	}
	.image2-size {
		height: 48rpx !important;
		width: 48rpx !important;
	}
	.flex20-clz {
		flex: 1;
	}
	.image8-size {
		height: 48rpx !important;
		width: 48rpx !important;
	}
	.flex21-clz {
		color: #fa240b;
		flex: 1;
	}
	.text19-clz {
		border: 2rpx solid #eee;
		border-bottom-left-radius: 40rpx;
		-webkit-animation-duration: 5000ms;
		color: #ffffff;
		animation-delay: 1000ms;
		-webkit-animation-delay: 1000ms;
		border-top-right-radius: 40rpx;
		right: -8rpx;
		background-color: rgba(255, 17, 17, 0.91);
		animation-duration: 5000ms;
		flex-shrink: 0;
		overflow: hidden;
		top: -8rpx;
		width: 16rpx !important;
		border-top-left-radius: 40rpx;
		border-bottom-right-radius: 40rpx;
		position: absolute;
		height: 16rpx !important;
	}
	.image5-size {
		height: 48rpx !important;
		width: 48rpx !important;
	}
	.flex23-clz {
		flex: 1;
	}
	.image3-size {
		height: 48rpx !important;
		width: 48rpx !important;
	}
	.flex26-clz {
		flex: 1;
	}
	.image4-size {
		height: 48rpx !important;
		width: 48rpx !important;
	}
	.container329916 {
		background-color: #f5f5f5;
	}
	.container {
		padding-bottom: 100px;
	}
</style>

Logo

低代码爱好者的网上家园

更多推荐