Merge branch 'feature/task1.0.0' into 'msb_test'

Feature/task1.0.0

See merge request yanxuan-frontend/shop-pc!23
merge-requests/25/merge
肖广 3 years ago
commit 7f37270236

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 815 B

@ -10,7 +10,8 @@
<BsLogin :visible.sync="loginVisible" />
<Header
:is-categroy-open="isHomePage"
:hide-bar-line="isHomePage"
:hide-bar-line="isHideBarLinePage"
:hide-sticky-shadow="isHideSeckillPage"
:show-categroy-tab="showCategroyTab"
:is-sticky="isSticky"
/>
@ -45,6 +46,12 @@ export default {
isHomePage() {
return this.$route.path === "/";
},
isHideBarLinePage() {
return ["/", "/seckill"].includes(this.$route.path);
},
isHideSeckillPage() {
return this.$route.path === "/seckill";
},
showCategroyTab() {
return !CATEGROY_HIDE_PAGES.some((reg) => {
return reg.test(this.$route.path);

@ -1,8 +1,11 @@
<template>
<div class="layout-header">
<!-- 滚动吸顶头部 -->
<template v-if="isSticky">
<div class="sticky-bar-header">
<div v-show="isSticky">
<div
class="sticky-bar-header"
:class="{ 'sticky-bar-header--hide-shadow': hideStickyShadow }"
>
<div class="sticky-bar-header__wrap flex flex-middle flex-between">
<div class="flex flex-middle">
<img
@ -23,12 +26,19 @@
</el-menu>
</div>
<div class="bar-header-wrap__icons flex flex-middle">
<img src="~/assets/img/layout/icon-search-sticky.png" />
<div class="header-wrap-icons__shop">
<img
src="~/assets/img/layout/icon-search-sticky.png"
@click="$router.push('/goods/list')"
/>
<div class="header-wrap-icons__shop" @click="$router.push('/cart')">
<img src="~/assets/img/layout/icon-shop-sticky.png" />
<span class="">3</span>
</div>
<div v-if="token" class="header-wrap-icons__login">
<div
v-if="token"
class="header-wrap-icons__login"
@click="$router.push('/account/home')"
>
<img :src="userInfo.avatar" />
</div>
<div
@ -41,7 +51,7 @@
</div>
</div>
</div>
</template>
</div>
<template>
<HeaderInfoBar />
<div class="default-bar-header">
@ -191,6 +201,12 @@ export default {
type: Boolean,
default: false,
},
// tab
hideStickyShadow: {
type: Boolean,
default: true,
},
},
data() {
return {
@ -207,7 +223,7 @@ export default {
label: "开发书籍",
value: `/goods/list?id=6&levelType=${CATEGROY_LEVEL.ONE}`,
},
{ label: "限时秒杀", value: "/sckill" },
{ label: "限时秒杀", value: "/seckill" },
],
categroyTwoVisible: false, //
categroyVisible: false, //
@ -305,6 +321,14 @@ export default {
z-index: 10;
background: #ffffff;
box-shadow: 0px 4px 10px 1px rgba(0, 0, 0, 0.1);
&--hide-shadow {
box-shadow: none;
/deep/.el-menu {
.is-active {
border-bottom: none !important;
}
}
}
.sticky-bar-header__wrap {
@include layout-box;
height: 100%;
@ -343,6 +367,7 @@ export default {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: cover;
}
}
.header-wrap-icons__unlogin {
@ -356,7 +381,7 @@ export default {
color: #666666;
.is-active {
color: #ff7f39;
border-bottom: 2px solid #ff823c;
border-bottom: 3px solid #ff823c;
}
.el-menu-item:hover {
color: #ff7f39;
@ -365,7 +390,8 @@ export default {
height: 100%;
line-height: 50px;
font-size: 16px;
margin: 0 20px;
margin: 0 30px;
padding: 0 5px;
}
}
}

@ -135,6 +135,7 @@ export default {
height: 65px;
border-radius: 50%;
margin-right: 16px;
object-fit: cover;
}
.home-head-info__wrap {
color: #666666;

@ -11,27 +11,29 @@
<div class="home-logisitcs-label">我的物流</div>
<!-- 无物流信息 -->
<div
v-if="loadFinish && total === 0"
v-if="loadFinish && list.length === 0"
class="home-logisitcs-empty flex flex-center flex-middle"
>
<img src="~/assets/img/account/home/logisitcs-empty.png" />
</div>
<!-- 有物流信息 -->
<div v-else v-loading="loading">
<div v-infinite-scroll="handleListload" class="home-logisitcs-content">
<div class="home-logisitcs-content">
<div
v-for="item in list"
:key="item.orderNo"
:key="item.orderId"
class="home-logisitcs-content__item flex flex-middle flex-between"
>
<div class="logisitcs-content-item__info flex flex-start">
<img :src="item.img" />
<img :src="item.cover" />
<div class="content-item-info__wrap flex-1">
<p class="item-info-wrap__title">
{{ item.title }}
{{ item.logisticsContext }}
</p>
<div class="item-info-wrap__orderNo">
<span>{{ `${item.companyName}: ${item.orderNo}` }}</span>
<span>{{
`${item.logistics.companyName}: ${item.logistics.trackingNo}`
}}</span>
<span class="item-info-wrap__orderNo--light">查看详情</span>
</div>
</div>
@ -39,7 +41,7 @@
<UiButton
type="yellow_line"
:radius="true"
@click="onOrderEnsure(item)"
@click="onOrderConfirm(item)"
>确认收货</UiButton
>
</div>
@ -47,27 +49,26 @@
</div>
<UiConfirm
title="确认收到货了吗?"
:visible.sync="ensureOrderVisible"
:visible.sync="confirmOrderVisible"
@confirm="handleOrderEnsure"
/>
</div>
</template>
<script>
import { Message } from "element-ui";
import UiButton from "@/components/UiButton.vue";
import UiConfirm from "@/components/UiConfirm.vue";
import { ApiGetOrderLogisticsList } from "@/plugins/api/order";
import {
ApiGetOrderLogisticsList,
ApiPutOrderReceive,
} from "@/plugins/api/order";
export default {
components: { UiButton, UiConfirm },
data() {
return {
total: 0,
query: {
pageIndex: 1,
length: 10,
},
selectOrderId: 0,
ensureOrderVisible: false,
confirmOrderVisible: false,
list: [],
loading: false,
loadFinish: false,
@ -80,31 +81,36 @@ export default {
//
async getLogisticsList() {
this.loading = true;
const { result } = await ApiGetOrderLogisticsList({ ...this.query });
const { result } = await ApiGetOrderLogisticsList();
this.loading = false;
this.loadFinish = true;
if (result) {
const { total, list } = result;
this.total = total;
if (list && list.length > 0) {
this.list = this.list.concat(list);
}
this.list = result.map((item) => {
const goods = item.products || [{ productImageUrl: "" }];
const logisticsList = item.logistics.logisticsDataList || [
{ context: "暂无物流信息" },
];
return {
...item,
cover: goods[0].productImageUrl, //
logisticsContext: logisticsList[0].context, //
};
});
}
},
onOrderEnsure({ orderNo }) {
this.selectOrderId = orderNo;
this.ensureOrderVisible = true;
onOrderConfirm({ orderId }) {
this.selectOrderId = orderId;
this.confirmOrderVisible = true;
},
//
handleOrderEnsure() {},
//
handleListload() {
console.log("enter");
if (this.total > 0 && this.list.length < this.total) {
//
this.query.pageIndex += 1;
async handleOrderEnsure() {
const { result } = await ApiPutOrderReceive({
orderId: this.selectOrderId,
});
this.confirmOrderVisible = false;
if (result) {
Message.success("收货成功");
this.getLogisticsList();
}
},
@ -164,6 +170,7 @@ export default {
font-size: 14px;
color: #666666;
margin-bottom: 8px;
@include ellipses(2);
}
.item-info-wrap__orderNo {
display: inline-block;

@ -7,11 +7,13 @@
-->
<template>
<div class="page">
<main class="main">
<div :class="listData && listData.length ? 'page' : ''">
<main class="main" v-if="listData && listData.length">
<nav class="main__nav">
<p class="main__nav-crumbs">
全部商品<i class="el-icon-arrow-right"></i>开发书籍
全部商品
<i class="el-icon-arrow-right"></i>
<!-- <span v-if="levelType == 2">{{}}</span> -->
</p>
<div class="main__nav-sort flex flex-middle" v-if="levelType == 1">
<span class="main__nav-sort-txt">分类 :</span>
@ -52,6 +54,7 @@
:item="item"
v-for="item in listData"
:key="item.id"
:isRecommend="isRecommend"
></UiGoodsItem>
</div>
<el-pagination
@ -64,6 +67,27 @@
>
</el-pagination>
</main>
<main v-else class="main">
<img class="main-none-img" src="@/assets/img/goods/none.png" alt="" />
<p class="main-none-txt">没有搜到你想要的商品哦换个关键词试试</p>
</main>
<section class="section">
<div class="section-title flex flex-between flex-middle">
<h3 class="section-title--txt">为你精选</h3>
<div class="section-title--btn flex" @click="getRecommendedGoodsList()">
<img src="@/assets/img/goods/each.png" alt="切换推荐" />
<span>换一组</span>
</div>
</div>
<div class="section-list">
<UiGoodsItem
:item="item"
v-for="item in recommendedData"
:key="item.id"
></UiGoodsItem>
</div>
</section>
</div>
</template>
<script>
@ -71,6 +95,7 @@ import {
ApiGetGoodsList,
ApiGetCategoryOneList,
ApiGetCategoryTwoAndGoods,
ApiGetRecommendedGoodsList,
} from "@/plugins/api/goods";
import Sort from "./module/SortItem.vue";
import UiGoodsItem from "@/components/UiGoodsItem.vue";
@ -86,6 +111,7 @@ export default {
// 0:,1:desc,2:asc,3:
sortType: 0,
categoryOneList: [],
recommendedData: [],
levelType: "",
levelId: "",
params: {
@ -107,12 +133,18 @@ export default {
this.levelId = this.$route.query.id || "";
this.levelType == 2 ? (this.params.categoryId = this.levelId) : "";
this.isRecommend = this.levelId == "recommend" ? true : false;
this.getRecommendedGoodsList();
if (this.levelType == 1) {
this.getCategoryTwoAndGoods();
} else {
this.getGoodsListData();
}
},
async getRecommendedGoodsList() {
let vm = this;
let res = await ApiGetRecommendedGoodsList();
vm.recommendedData = res.result;
},
async getCategoryOneList() {
let res = await ApiGetCategoryOneList();
this.categoryOneList = res.result;
@ -265,5 +297,57 @@ export default {
color: #fff;
}
}
&-none-img {
display: block;
width: 228px;
height: 144px;
margin: 60px auto 20px;
}
&-none-txt {
width: 100%;
text-align: center;
font-size: 14px;
font-family: PingFang SC-常规体, PingFang SC;
font-weight: normal;
color: #999999;
}
}
.section {
width: 100%;
padding: 30px 0 40px;
background: #f8f8f8;
&-title {
@include layout-box;
&--txt {
font-size: 24px;
font-family: Microsoft YaHei-Bold, Microsoft YaHei;
font-weight: bold;
color: #333333;
}
&--btn {
width: 140px;
cursor: pointer;
img {
width: 27px;
height: 27px;
margin-right: 5px;
}
span {
font-size: 18px;
font-family: Microsoft YaHei-Regular, Microsoft YaHei;
font-weight: 400;
color: #999999;
}
}
}
&-list {
@include layout-box;
padding-top: 40px;
display: grid;
grid-template-columns: repeat(auto-fill, 232px);
justify-content: space-between;
grid-row-gap: 10px;
}
}
</style>

@ -12,8 +12,8 @@
<strong class="home-sckill-title">限时秒杀</strong>
<div class="home-sckill-wrap">
<div class="home-sckill-wrap__tip">
<strong>10:00</strong>
<span>点场 距结束</span>
<strong>{{ data.activityTimeVO.timeName }}</strong>
<span>{{ seckillText }}</span>
</div>
<div class="home-sckill-wrap__countdown flex flex-middle flex-center">
<div class="sckill-wrap-countdown__time">{{ countdown.hour }}</div>
@ -66,6 +66,7 @@
</template>
<script>
import _ from "lodash";
import { FormatDate } from "@/plugins/utils";
const CAROUSEL_COUNT = 5; //
@ -81,6 +82,9 @@ export default {
return {
bkgUrl: require("~/assets/img/sckill/bkg-small.png"),
goodsList: [],
seckillTime: 0, //
seckillText: "", //
timeInterval: null, //
};
},
watch: {
@ -88,22 +92,37 @@ export default {
deep: true,
immediate: true,
handler(val) {
const { activityProductListVO: products } = val;
const {
currentTime,
activityProductListVO: products,
activityTimeVO: { activityEndTime, activityStartTime },
} = val;
if (products && products.length > 0) {
this.getFormatData(products);
}
this.getSeckillData({
currentTime,
startTime: activityStartTime,
endTime: activityEndTime,
});
},
},
},
computed: {
//
countdown() {
const date = FormatDate(this.seckillTime, "hh:mm:ss");
const [hour, minute, second] = date.split(":");
return {
hour: "01",
minute: "32",
second: "09",
hour,
minute,
second,
};
},
},
destroyed() {
clearInterval(this.timeInterval);
},
methods: {
//
getFormatData(list) {
@ -118,6 +137,32 @@ export default {
this.goodsList.push(goodsListItem);
}
},
//
getSeckillData({ current, startTime, endTime }) {
current = new Date(current);
startTime = new Date(startTime);
endTime = new Date(endTime);
if (current > startTime && current < endTime) {
//
this.seckillText = "距结束";
this.seckillTime = endTime - current;
} else {
//
this.seckillText = "距开始";
this.seckillTime = startTime - current;
}
this.setCountDownInterval();
},
//
setCountDownInterval() {
this.timeInterval = setInterval(() => {
if (this.seckillTime <= 0) {
clearInterval(this.timeInterval);
}
this.seckillTime -= 1000;
}, 1e3);
},
onJumpSeckill() {
this.$router.push("/seckill");
},
@ -149,6 +194,7 @@ export default {
font-size: 0;
strong {
font-size: 18px;
margin-right: 4px;
}
span {
font-size: 14px;

@ -6,51 +6,50 @@
* @Description: file content
-->
<template>
<div class="sckill">
<div class="seckill">
<div v-show="isSticky" class="seckill-header-sticky">
<TabBar
v-model="query.activityTimeId"
:list="tabList"
@tab-click="getGoodsList"
/>
</div>
<!-- 秒杀时间段 -->
<div
class="sckill-header"
class="seckill-header"
:style="{ backgroundImage: `url(${bkgSckill})` }"
>
<div class="sckill-header-tabbar flex">
<div
v-for="item in tabList"
:key="item.id"
@click="onTabSelect(item.id)"
class="sckill-header-tabbar__item flex flex-middle flex-center"
:class="{
'sckill-header-tabbar__item--active':
item.id === query.activityTimeId,
}"
>
<strong class="header-tabbar-item__time">18:00</strong>
<div class="header-tabbar-item__tip">抢购中</div>
</div>
<div class="seckill-header-tabbar">
<TabBar
v-model="query.activityTimeId"
:list="tabList"
@tab-click="getGoodsList"
/>
</div>
</div>
<div class="sckill-bar flex flex-middle flex-center">
<p>本场秒杀即将开抢距开始还剩</p>
<div class="sckill-bar-countdown flex flex-middle">
<div class="sckill-bar-countdown__time">{{ countdown.hour }}</div>
<span class="sckill-bar-countdown--mark">:</span>
<div class="sckill-bar-countdown__time">{{ countdown.minute }}</div>
<span class="sckill-bar-countdown--mark">:</span>
<div class="sckill-bar-countdown__time">{{ countdown.second }}</div>
<div class="seckill-bar flex flex-middle flex-center">
<p>{{ seckillTip }}</p>
<div class="seckill-bar-countdown flex flex-middle">
<div class="seckill-bar-countdown__time">{{ countdown.hour }}</div>
<span class="seckill-bar-countdown--mark">:</span>
<div class="seckill-bar-countdown__time">{{ countdown.minute }}</div>
<span class="seckill-bar-countdown--mark">:</span>
<div class="seckill-bar-countdown__time">{{ countdown.second }}</div>
</div>
</div>
<!-- 秒杀商品列表 -->
<div v-loading="loading" class="sckill-products flex flex-wrap">
<div v-loading="loading" class="seckill-products flex flex-wrap">
<div
v-for="item in goodsList"
:key="item.productId"
@click="onJumpGoodsDetail(item.productId)"
class="sckill-products-wrap"
class="seckill-products-wrap"
>
<img
:src="item.productMainPicture"
class="sckill-products-wrap__cover"
class="seckill-products-wrap__cover"
/>
<div class="sckill-products-wrap__content">
<div class="seckill-products-wrap__content">
<p class="products-wrap-content__title">{{ item.productName }}</p>
<div class="products-wrap-content__price">
<strong>{{ item.activityPrice }}</strong>
@ -69,7 +68,7 @@
</div>
</div>
<!-- 分页 -->
<div class="sckill-pagination flex flex-right">
<div class="seckill-pagination flex flex-right">
<el-pagination
background
:current-page="currentPage"
@ -86,21 +85,27 @@
</template>
<script>
import { ApiGetSeckillTimes, ApiGetSeckillGoods } from "@/plugins/api/seckill";
import { FormatDate } from "@/plugins/utils";
import TabBar from "./module/TabBar.vue";
export default {
name: "Sckill",
components: { TabBar },
data() {
return {
bkgSckill: require("~/assets/img/sckill/bkg-large.png"),
tabList: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], //
tabList: [], //
goodsList: [], //
total: 0,
currentPage: 0,
loading: false,
query: {
pageIndex: 1,
length: 12,
length: 16,
activityTimeId: 1,
},
ticking: false,
isSticky: false,
};
},
computed: {
@ -111,16 +116,55 @@ export default {
second: "09",
};
},
//
currentTabItem() {
const tabItem = this.tabList.find(
(item) => item.id === this.query.activityTimeId
);
return tabItem || null;
},
//
seckillTip() {
if (this.currentTabItem) {
return this.currentTabItem.isInProgress
? "本场正在秒杀中,好物转瞬即逝,不要错过哦~距结束仅剩"
: "本场秒杀即将开抢,距开始还剩";
}
return "";
},
},
created() {
this.getSeckillTimes();
},
mounted() {
//
window.addEventListener("scroll", this.scrollEventMethod);
},
destroyed() {
window.removeEventListener("scroll", this.scrollEventMethod);
},
methods: {
scrollEventMethod() {
const that = this;
//
if (!that.ticking) {
window.requestAnimationFrame(function () {
that.ticking = false;
that.isSticky = window.scrollY > 400;
});
that.ticking = true;
}
},
async getSeckillTimes() {
const { result } = await ApiGetSeckillTimes();
if (result && result.length > 0) {
this.tabList = result;
this.query.activityTimeId = result[0].id;
const inProgressItem = result.find((item) => item.isInProgress);
this.query.activityTimeId =
(inProgressItem && inProgressItem.id) || result[0].id;
this.getGoodsList();
}
},
@ -144,72 +188,48 @@ export default {
this.query.pageIndex = page;
this.getGoodsList();
},
onTabSelect(id) {
handleTabSelect(id) {
this.query.activityTimeId = id;
Object.assign(this.query, {
pageIndex: 1,
length: 10,
length: 16,
});
},
},
};
</script>
<style lang="scss" scoped>
.sckill {
.seckill {
background: #f8f8f8;
padding-bottom: 42px;
.sckill-header {
.seckill-header-sticky {
position: fixed;
left: 50%;
transform: translate(-50%);
top: 50px;
z-index: 1;
@include layout-box;
}
.seckill-header {
position: relative;
width: 100%;
height: 156px;
background-size: 100% 100%;
.sckill-header-tabbar {
.seckill-header-tabbar {
@include layout-box;
position: absolute;
left: 50%;
bottom: 0;
transform: translate(-50%);
height: 60px;
border-radius: 4px 4px 0px 0px;
overflow: hidden;
cursor: pointer;
.sckill-header-tabbar__item {
width: 240px;
font-size: 12px;
background: #ffffff;
color: #666666;
.header-tabbar-item__time {
font-size: 18px;
color: #333333;
margin-right: 22px;
}
.header-tabbar-item__tip {
width: 69px;
height: 22px;
text-align: center;
border: 1px solid #999999;
border-radius: 2px;
}
&--active {
background: linear-gradient(270deg, #ffa25a 0%, #ff7f39 100%);
color: #ffffff;
.header-tabbar-item__time {
font-size: 24px;
color: #ffffff;
}
.header-tabbar-item__tip {
border-color: #ffffff;
}
}
}
}
}
.sckill-bar {
.seckill-bar {
height: 60px;
line-height: 60px;
text-align: center;
font-size: 14px;
color: #666666;
.sckill-bar-countdown {
.seckill-bar-countdown {
margin-left: 25px;
font-weight: bold;
&__time {
@ -228,23 +248,24 @@ export default {
}
}
}
.sckill-products,
.sckill-pagination {
.seckill-products,
.seckill-pagination {
@include layout-box;
}
.sckill-products {
.sckill-products-wrap {
.seckill-products {
.seckill-products-wrap {
width: 24%;
background: #ffffff;
margin: 15px 0 60px 0;
cursor: pointer;
&:not(:nth-child(4n)) {
margin-right: calc(4% / 3);
}
.sckill-products-wrap__cover {
.seckill-products-wrap__cover {
width: 100%;
height: 288px;
}
.sckill-products-wrap__content {
.seckill-products-wrap__content {
padding: 20px 16px;
.products-wrap-content__title {
@include ellipsis;
@ -287,7 +308,7 @@ export default {
}
}
}
.sckill-pagination {
.seckill-pagination {
margin-top: 60px;
/deep/.el-pagination {
.btn-prev,

@ -0,0 +1,81 @@
<template>
<div class="seckill-tabbar flex flex-between">
<div
v-for="item in list"
:key="item.id"
@click="onTabClick(item.id)"
class="sckill-header-tabbar__item flex flex-middle flex-center"
:class="{
'sckill-header-tabbar__item--active': item.id === value,
}"
>
<strong class="header-tabbar-item__time">{{ item.timeName }}</strong>
<div class="header-tabbar-item__tip">
{{ item.isInProgress ? "抢购中" : "即将开抢" }}
</div>
</div>
</div>
</template>
<script>
export default {
name: "SeckillTabBar",
props: {
list: {
type: Array,
default: () => [],
},
value: {
type: Number,
default: 0,
},
},
methods: {
onTabClick(id) {
// tab
if (id === this.value) {
return;
}
this.$emit("input", id);
this.$emit("tab-click");
},
},
};
</script>
<style lang="scss" scoped>
.seckill-tabbar {
width: 100%;
height: 60px;
cursor: pointer;
overflow: hidden;
.sckill-header-tabbar__item {
width: 100%;
font-size: 12px;
background: #ffffff;
color: #666666;
.header-tabbar-item__time {
font-size: 18px;
color: #333333;
margin-right: 22px;
}
.header-tabbar-item__tip {
width: 69px;
height: 22px;
line-height: 22px;
text-align: center;
border: 1px solid #999999;
border-radius: 2px;
}
&--active {
background: linear-gradient(270deg, #ffa25a 0%, #ff7f39 100%);
color: #ffffff;
.header-tabbar-item__time {
font-size: 24px;
color: #ffffff;
}
.header-tabbar-item__tip {
border-color: #ffffff;
}
}
}
}
</style>

@ -68,15 +68,15 @@ export const ApiGetOrderLogistics = ({orderId}) =>
* 获取物流列表
* @param {*} params
*/
export const ApiGetOrderLogisticsList = (params) =>
ToAsyncAwait(axiosTk.get('http://yapi.smart-xwork.cn/mock/148902/logisitcs/list'), { params });
export const ApiGetOrderLogisticsList = () =>
ToAsyncAwait(axiosTk.get(`${BASE_URL}/app/tradeOrder/listReceiveOrder`));
/**
* 确认收货
* @param {*} orderId
*/
export const ApiPutOrderReceive = (params) =>
ToAsyncAwait(axiosTk.put(`${BASE_URL}/app/tradeOrder/receive`, params));
export const ApiPutOrderReceive = (data) =>
ToAsyncAwait(axiosTk.put(`${BASE_URL}/app/tradeOrder/receive`, data));
/**
* 提交订单
* @param {*} data

Loading…
Cancel
Save