Browse Source

处置流程和常用语配置

wangqin
zhoule 12 months ago
parent
commit
4a43645204
  1. 16
      ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/images/bg-active.svg
  2. 12
      ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/images/bg.svg
  3. 274
      ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/index.vue
  4. 183
      ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/commonPhrases/index.vue
  5. 168
      ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/disposalProcess/index.vue
  6. 27
      ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/index.vue

16
ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/images/bg-active.svg

@ -0,0 +1,16 @@
<svg width="83" height="26" viewBox = "min-x min-y width height" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1142814474">
<path id="Rectangle 3602" d="M10.25 25H6C3.23858 25 1 22.7614 1 20V6C1 3.23858 3.23858 1 6 1H10.25M74.75 25H77C79.7614 25 82 22.7614 82 20V6C82 3.23858 79.7614 1 77 1H74.75" stroke="url(#paint0_linear_308_581)" stroke-width="1.5"/>
<path id="Rectangle 3618" d="M82 20V6C82 3.23858 79.7614 1 77 1H74.75H10.25H6C3.23858 1 1 3.23858 1 6V20C1 22.7614 3.23858 25 6 25H10.25H74.75H77C79.7614 25 82 22.7614 82 20Z" fill="url(#paint1_linear_308_581)"/>
</g>
<defs>
<linearGradient id="paint0_linear_308_581" x1="40.0452" y1="5.58015" x2="40.0452" y2="24.4504" gradientUnits="userSpaceOnUse">
<stop stop-color="#39D5BF"/>
<stop offset="1" stop-color="#1FAED6"/>
</linearGradient>
<linearGradient id="paint1_linear_308_581" x1="41.5" y1="1" x2="41.5" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#20AFD7" stop-opacity="0"/>
<stop offset="1" stop-color="#39D5BF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

12
ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/images/bg.svg

@ -0,0 +1,12 @@
<svg width="83" height="26" viewBox="min-x min-y width height" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1142814479">
<path id="Rectangle 3602" d="M10.25 25H6C3.23858 25 1 22.7614 1 20V6C1 3.23858 3.23858 1 6 1H10.25M74.75 25H77C79.7614 25 82 22.7614 82 20V6C82 3.23858 79.7614 1 77 1H74.75" stroke="#CACACA" stroke-width="1.5"/>
<path id="Rectangle 3618" d="M82 20V6C82 3.23858 79.7614 1 77 1H74.75H10.25H6C3.23858 1 1 3.23858 1 6V20C1 22.7614 3.23858 25 6 25H10.25H74.75H77C79.7614 25 82 22.7614 82 20Z" fill="url(#paint0_linear_308_590)"/>
</g>
<defs>
<linearGradient id="paint0_linear_308_590" x1="41.5" y1="1" x2="41.5" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#CACACA" stop-opacity="0"/>
<stop offset="1" stop-color="#CACACA"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 803 B

274
ruoyi-ui/src/views/JiHeExpressway/components/TimeLine/LineClick/index.vue

@ -0,0 +1,274 @@
<template>
<div :class='["TimeLine1", { "auto-size": autoSize }]' ref="TimeLine1Ref">
<!-- 节点 -->
<div class="node" v-for="(item, index) in data" :key="index" ref="nodeRefs">
<span class="top-label keep-ratio-bottom">
<slot name="bottom-label" :data="item">
{{ item.time }}
</slot>
</span>
<div class="center">
<div class="line" v-if="!index" :style="{ width: `${nodeLinesWidth[-1]}px` }" />
<div class="circle keep-ratio" :style="{ '--active-color': !item.isActive ? normalColor : activeColor }">
</div>
<div class="line" :style="{
width: `${nodeLinesWidth[index]}px`,
borderImage: getBorderImageStyle(item),
}" />
</div>
<slot name="bottom-label" :data="item">
<div class="bottom keep-ratio" @click="onClick(item)"
:style="{ backgroundImage: `url(${require(`./images/bg${item.isActive ? '-active' : ''}.svg`)})` }">
{{ item.label }}
</div>
</slot>
</div>
</div>
</template>
<script>
function getPositionAtCenter(element) {
const { top, left } = element.getBoundingClientRect();
return {
x: left,
y: top
};
}
function getDistanceBetweenElements(domA, domB) {
const domAPosition = getPositionAtCenter(domA);
const domBPosition = getPositionAtCenter(domB);
return Math.hypot(domAPosition.x - domBPosition.x, domAPosition.y - domBPosition.y);
}
export default {
name: 'TimeLine1',
props: {
data: {
type: Array,
default: () => Array.from({ length: 15 }).map(() => ({
time: "16.36",
label: "阿发",
isActive: false
}))
},
activeColor: {
type: String,
default: "#39D5BF"
},
normalColor: {
type: String,
default: "#ccc"
},
lineActiveColor: {
type: String,
default: "linear-gradient(90deg, rgba(59, 216, 188, 1), rgba(29, 171, 215, 1)}) 2 2"
},
lineNormalColor: {
type: String,
default: null
},
//
autoSize: {
type: Boolean,
default: true
},
filterDistance: {
type: Function,
default: null
}
},
data() {
return {
nodeLinesWidth: {}
}
},
watch: {
data: {
handler(data) {
let needFilter = false
const nodeLinesWidth = [];
const filterDistance = num => needFilter ? this.filterDistance(num) : num;
const removeDistance = 20 + 4 * 2;
function getDistance(index) {
return filterDistance(getDistanceBetweenElements(
this.$refs.nodeRefs[index].querySelector('.center'),
this.$refs.nodeRefs[index + 1].querySelector('.center'))
) - removeDistance
};
function getSpecialDistance(index) {
return filterDistance(getDistanceBetweenElements(
this.$refs.nodeRefs[index].parentElement,
this.$refs.nodeRefs[index].querySelector('.center'))
) - removeDistance
}
const getLineWidths = () => {
nodeLinesWidth.length = 0;
data.forEach((_, index) => {
if (index === data.length - 1) {
if (this.autoSize) {
nodeLinesWidth[-1] = getSpecialDistance.call(this, 0)
nodeLinesWidth[data.length - 1] = getSpecialDistance.call(this, data.length - 1)
}
return
};
nodeLinesWidth[index] = getDistance.call(this, index);
});
this.nodeLinesWidth = nodeLinesWidth;
}
this.$nextTick(() => {
const timeLine1RefDom = this.$refs.TimeLine1Ref;
if (timeLine1RefDom.clientWidth != timeLine1RefDom.getBoundingClientRect().width) {
needFilter = true
}
getLineWidths();
})
},
immediate: true
}
},
methods: {
getBorderImageStyle(item) {
const linearColor = item.isActive ? (this.lineActiveColor || `${this.activeColor}, ${this.activeColor}`) : (this.lineNormalColor || `${this.normalColor}, ${this.normalColor}`)
return `linear-gradient(90deg, ${linearColor}) 2 2`
},
onClick(item) {
this.data.forEach(it => {
if (it.id == item.id) {
it.isActive = true;
} else {
it.isActive = false;
}
})
this.$emit('update:tableData', item.id);
// console.log('data', item)
}
}
}
</script>
<style lang='scss' scoped>
div.auto-size {
justify-content: space-between;
overflow: hidden;
.node {
flex: 1;
min-width: auto !important;
&:first-child,
&:last-child {
.line {
width: auto;
}
}
}
}
.TimeLine1 {
color: #fff;
width: auto;
font-size: 14px;
font-family: PingFang SC, PingFang SC;
font-weight: 400;
color: #FFFFFF;
display: flex;
flex-wrap: nowrap;
overflow: auto;
gap: 15px;
padding-left: 15px;
.node {
display: flex;
align-items: center;
// justify-content: center;
flex-direction: column;
gap: 3px;
min-width: 90px;
.top-label {
line-height: 16px;
height: 24px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
position: relative;
.circle {
border: 1px solid var(--active-color, #39D5BF);
border-radius: 50%;
width: 15px;
height: 15px;
position: relative;
&::before {
content: "";
position: absolute;
width: 72%;
height: 72%;
border-radius: 50%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: var(--active-color, #39D5BF);
}
}
.line {
position: absolute;
height: 0px;
opacity: 1;
border: 2px solid;
//border-image: linear-gradient(90deg, rgba(59, 216, 188, 1), rgba(29, 171, 215, 1)) 2 2;
border-image: linear-gradient(90deg, rgb(204, 204, 204), rgb(204, 204, 204)) 2 / 1 / 0 stretch;
&:first-child {
right: calc(50% + 8px + 6px);
}
&:last-child {
left: calc(50% + 8px + 6px);
}
}
}
&:first-child,
&:last-child {
.line {
width: 60px;
}
}
.bottom {
line-height: 16px;
background-repeat: no-repeat;
background-size: 100% 100%;
min-width: 90px;
text-align: center;
padding: 6px 12px;
height: 27px;
}
}
}
</style>

183
ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/commonPhrases/index.vue

@ -1,17 +1,38 @@
<template>
<Dialog v-model="modelVisible" title="配置常用语">
<Button style="margin: 10px 0 20px 20px; width: 100px;" @click.native="onAdd">添加</Button>
<!-- <Button style="margin: 10px 0 20px 20px; width: 100px;" @click.native="onAdd">添加</Button> -->
<div class="header">
<el-form ref="form" label-width="120px">
<el-form-item label="事件类型">
<el-select v-model="eventType" placeholder="请选择" style="width: 220px">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<div>
<TimeLine1 :data="process" @update:tableData="updateTableData" />
</div>
<div class="EventDetail">
<Table :data="tableData">
<ElTableColumn prop="phrases" label="常用语" width="720" align="center">
<Table :data="getTableData()" :show-header="false">
<ElTableColumn label="步骤" width="110" align="center">
<template slot-scope="scope">
<ElInput type="textarea" v-model="scope.row.phrases" :autosize="{ minRows: 2, maxRows: 2 }"
:maxlength="150" showWordLimit placeholder="请输入常用语内容" />
{{ `${scope.$index + 1}` }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="110" align="center">
<ElTableColumn prop="phrases" label="常用语" width="550" align="center">
<template slot-scope="scope">
<ElButton type="text" style="color: #ea4d2d;" @click.native="onDel(scope.$index)">删除</ElButton>
<ElInput type="textarea" style="margin-top: 5px;" v-model="scope.row.phrases"
:autosize="{ minRows: 2, maxRows: 2 }" :maxlength="150" showWordLimit placeholder="请输入常用语内容" />
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="160" align="center">
<template slot-scope="scope">
<ElButton class="elButton" icon="el-icon-plus" plain size="mini"
@click.native="onAdd(scope.row.id)" />
<ElButton class="elButton" icon="el-icon-delete" plain size="mini"
@click.native="onDel(scope.$index)" />
</template>
</ElTableColumn>
</Table>
@ -30,6 +51,7 @@ import Dialog from "@screen/components/Dialog/index";
import Table from '@screen/components/Table.vue';
import Button from '@screen/components/Buttons/Button.vue';
import request from "@/utils/request";
import TimeLine1 from "@screen/components/TimeLine/LineClick/index";
import { Message } from 'element-ui'
@ -39,6 +61,7 @@ export default {
Dialog,
Button,
Table,
TimeLine1
},
model: {
prop: 'visible',
@ -46,11 +69,66 @@ export default {
},
props: {
visible: Boolean,
eventType: Number
eventType: Number,
// process: {
// type: Array,
// default: () => []
// }
},
data() {
return {
tableData: []
tableData: [{
phrases: ''
}],
// eventType: 1,
options: [
{
value: 1,
label: '交通事故'
},
{
value: 2,
label: '车辆故障'
},
{
value: 3,
label: '交通管制'
},
{
value: 4,
label: '交通拥堵'
},
{
value: 5,
label: '非法上路'
},
{
value: 6,
label: '路障清除'
},
{
value: 7,
label: '施工建设'
},
{
value: 8,
label: '服务区异常'
},
{
value: 9,
label: '设施设备隐患'
},
{
value: 10,
label: '异常天气'
},
{
value: 11,
label: '其他事件'
}
],
process: [],
id: 0
}
},
computed: {
@ -74,31 +152,79 @@ export default {
method: "get",
}).then(result => {
if (result.code != 200) return Message.error(result.msg);
result.data.processConfigList?.forEach(it => {
const phrs = it.commonPhrases.split(',');
phrs?.forEach(phr => {
if (phr && !this.tableData.find(op => op.phrases == phr)) this.tableData.push({ phrases: phr })
//
this.process = [];
this.tableData = [];
result.data.processConfigList?.forEach((it, index) => {
let commonPhrasesArr = it.commonPhrases ? it.commonPhrases.split(',') : [''];
let phrs = [];
commonPhrasesArr?.forEach(phr => {
phrs.push({ id: it.id, phrases: phr })
})
this.process.push({
...it,
phrs: phrs,
label: it.processNode,
isActive: index == 0 ? true : false,
})
if (index == 0) {
this.id = it.id;
this.tableData = phrs;
}
})
})
},
onAdd() {
getTableData() {
let rows = this.process.find(item => item.id == this.id);
return rows?.phrs || [];
},
updateTableData(id = 1) {
this.id = id;
this.tableData = [];
let pros = this.process.find(item => item.id == id);
this.tableData = pros.phrs;
},
onAdd(id) {
this.tableData.push({
id: id,
phrases: ''
})
},
onDel(index) {
if (this.tableData.length <= 1) {
return Message.warning('最后一项不可删除!');
}
this.tableData.splice(index, 1)
},
submitTable() {
this.$emit('update:phrasesData', this.tableData)
this.modelVisible = false;
console.log(this.tableData)
let data = []
this.process.forEach((lc) => {
let commonPhrases = [];
lc.phrs.forEach(phr => { if (phr.phrases) commonPhrases.push(phr.phrases) })
data.push({
commonPhrases: commonPhrases.join(','),
id: lc.id,
eventType: lc.eventType,
nodeNode: lc.nodeNode,
processNode: lc.processNode
})
})
console.log('data', data)
// return;
request({
url: `/business/dcEventType/updateDcProcessConfig`,
method: "post",
data: {
eventType: this.eventType,
processConfigList: data
}
}).then(result => {
if (result.code != 200) return Message.error(result.msg);
Message.success(result.msg);
this.modelVisible = false;
this.$emit('reInitData', true)
})
}
}
}
@ -110,6 +236,7 @@ export default {
gap: 9px;
width: 836px;
height: 768px;
margin-top: 20px;
flex-direction: column;
.video-pic {
@ -118,5 +245,19 @@ export default {
gap: 15px
}
}
.elButton {
background: #2ba8c3;
border-radius: 2px 2px 2px 2px;
color: #FFFFFF;
}
.elButton:hover,
.elButton:focus {
background: #2ba8c3;
border-radius: 2px 2px 2px 2px;
border-color: #FFFFFF;
color: #FFFFFF;
}
</style>

168
ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/disposalProcess/index.vue

@ -1,34 +1,46 @@
<template>
<Dialog v-model="modelVisible" title="配置处置流程">
<Button style="margin: 10px 0 20px 20px; width: 100px;" @click.native="onAdd">添加</Button>
<div class="header">
<el-form ref="form" label-width="120px">
<el-form-item label="事件类型">
<el-select v-model="eventType" placeholder="请选择" style="width: 220px">
<el-option v-for="item in eventTypeOptions" :key="item.value" :label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<div class="EventDetail">
<Table :data="tableData">
<Table :data="tableData" :show-header="false">
<ElTableColumn label="步骤" width="110" align="center">
<template slot-scope="scope">
{{ `步骤${scope.$index + 1}` }}
{{ `${scope.$index + 1}` }}
</template>
</ElTableColumn>
<ElTableColumn prop="phrases" label="处置流程" width="610" align="center">
<ElTableColumn prop="phrases" label="处置流程" width="570" align="center">
<template slot-scope="scope">
<ElForm :model="scope.row" inline>
<ElFormItem label="流程名称">
<ElInput v-model="scope.row.processNode" placeholder="请输入流程名称" />
<ElForm :model="scope.row" inline :ref="'scopeForm' + scope.$index">
<!-- <ElFormItem label="流程名称" :rules="[{ required: true, message: '流程名称不能为空' }]">
<ElInput v-model="scope.row.processNode" placeholder="请输入流程名称" />
</ElFormItem>
<ElFormItem label="常用语">
</ElFormItem> -->
<!-- <ElFormItem label="常用语">
<ElSelect class="disposal-process-select" v-model="scope.row.commonPhrases" multiple
:collapse-tags="true">
<ElOption v-for="item in options" :key="item.key || item.value" :label="item.label"
:value="item.key || item.value">
</ElOption>
</ElSelect>
</ElFormItem>
</ElFormItem> -->
</ElForm>
<!-- <Form :formList="formList" :dFormData="scope.row" label-width="100px" /> -->
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="110" align="center">
<ElTableColumn label="操作" width="150" align="center">
<template slot-scope="scope">
<ElButton type="text" style="color: #ea4d2d;" @click.native="onDel(scope.$index)">删除</ElButton>
<ElButton class="elButton" icon="el-icon-plus" plain size="mini" @click.native="onAdd" />
<ElButton class="elButton" icon="el-icon-delete" plain size="mini" @click.native="onDel(scope.$index)" />
</template>
</ElTableColumn>
</Table>
@ -73,7 +85,13 @@ export default {
},
data() {
return {
tableData: [],
tableData: [{
id: '',
eventType: '',
nodeNode: '',
processNode: '',
commonPhrases: ''
}],
formList: [
{
label: "流程名称:",
@ -93,7 +111,53 @@ export default {
}
},
],
options: []
options: [],
eventTypeOptions: [
{
value: 1,
label: '交通事故'
},
{
value: 2,
label: '车辆故障'
},
{
value: 3,
label: '交通管制'
},
{
value: 4,
label: '交通拥堵'
},
{
value: 5,
label: '非法上路'
},
{
value: 6,
label: '路障清除'
},
{
value: 7,
label: '施工建设'
},
{
value: 8,
label: '服务区异常'
},
{
value: 9,
label: '设施设备隐患'
},
{
value: 10,
label: '异常天气'
},
{
value: 11,
label: '其他事件'
}
]
}
},
computed: {
@ -113,33 +177,46 @@ export default {
getProcess() {
let addFlg = true;
this.tableData = [];
this.phrasesData.forEach(it => {
this.options.push({
key: it.phrases
})
addFlg = false;
})
// this.phrasesData.forEach(it => {
// this.options.push({
// key: it.phrases
// })
// addFlg = false;
// })
request({
url: `/business/dcEventType/${this.eventType}`,
method: "get",
}).then(result => {
if (result.code != 200) return Message.error(result.msg);
this.eventType = result.data.eventType;
result.data.processConfigList?.forEach(it => {
const phrs = it.commonPhrases.split(',');
if (addFlg) {
phrs?.forEach(phr => {
if (phr && !this.options.find(op => op.key == phr)) this.options.push({ key: phr })
})
}
if (phrs && phrs[0]) {
it.commonPhrases = phrs
}
})
// this.eventType = result.data.eventType;
// result.data.processConfigList?.forEach(it => {
// const phrs = it.commonPhrases.split(',');
// if (addFlg) {
// phrs?.forEach(phr => {
// if (phr && !this.options.find(op => op.key == phr)) this.options.push({ key: phr })
// })
// }
// if (phrs && phrs[0]) {
// it.commonPhrases = phrs
// }
// })
if (result.data.processConfigList.length > 0) {
this.tableData = result.data.processConfigList;
} else {
this.tableData = [
{
id: '',
eventType: '',
nodeNode: '',
processNode: '',
commonPhrases: ''
}
]
}
this.tableData = result.data.processConfigList;
// console.log('this.tableData', this.tableData)
})
@ -149,26 +226,32 @@ export default {
onAdd() {
this.tableData.push({
// eventType: this.eventType,
commonPhrases: [],
commonPhrases: '',
processNode: '',
// nodeNode: 1
})
},
onDel(index) {
if (this.tableData.length <= 1) {
return Message.warning('最后一项不可删除!');
}
this.tableData.splice(index, 1)
},
submitTable() {
let data = []
let flg = false;
this.tableData.forEach((it, index) => {
if (!it.processNode) return flg = true;
data.push({
commonPhrases: it.commonPhrases.join(','),
commonPhrases: it.commonPhrases,
eventType: this.eventType,
nodeNode: index + 1,
processNode: it.processNode
})
})
if (flg) return Message.error('节点名称不能为空');
// console.log(data)
// return;
request({
url: `/business/dcEventType/updateDcProcessConfig`,
method: "post",
@ -202,6 +285,19 @@ export default {
gap: 15px
}
}
.elButton {
background: #2ba8c3;
border-radius: 2px 2px 2px 2px;
color: #FFFFFF;
}
.elButton:hover,
.elButton:focus {
background: #2ba8c3;
border-radius: 2px 2px 2px 2px;
border-color: #FFFFFF;
color: #FFFFFF;
}
</style>
<style lang="scss">

27
ruoyi-ui/src/views/JiHeExpressway/pages/control/event/emergencyProcessManagement/index.vue

@ -28,16 +28,16 @@
<ElTableColumn prop="processConfig" label="处置流程" />
<ElTableColumn label="操作" width="210">
<template slot-scope="scope">
<ElButton type="text" style="color: #00EBC1;" @click="showPhrases(scope.row.eventType)">常用语</ElButton>
<ElButton type="text" style="color: #00D1FF;" @click="showDisposal(scope.row.eventType)">流程配置</ElButton>
<ElButton type="text" style="color: #00EBC1;" @click="showPhrases(scope.row)">常用语</ElButton>
</template>
</ElTableColumn>
</Table>
</div>
<!-- 配置常用户弹窗 -->
<CommonPhrases :visible="isShowPhrases" :eventType="eventType" @update:value="onClosePhrases"
@update:phrasesData="onUpdatePhrasesData" />
<CommonPhrases :visible="isShowPhrases" :eventType="eventType" :process="process" @update:value="onClosePhrases"
@update:phrasesData="onUpdatePhrasesData" @reInitData="initData" />
<!-- "流程配置"弹出框 -->
<DisposalProcess :visible="isShowDisposal" :eventType="eventType" :phrasesData="phrasesData"
@update:value="onCloseDisposal" @reInitData="initData" />
@ -82,7 +82,8 @@ export default {
pageSize: 20,
pageNum: 1,
},
phrasesData: []
phrasesData: [],
process: []
}
},
created() {
@ -110,9 +111,23 @@ export default {
this.searchData.pageSize = pageSize;
this.getData();
},
showPhrases(eventType) {
showPhrases(data) {
if (data?.processConfigList.length <= 0) {
Message.warning('请先配置流程!');
return;
}
let process = []
data.processConfigList.forEach(it => {
process.push({
id: it.id,
commonPhrases: it.commonPhrases,
label: it.processNode,
isActive: false,
})
})
this.process = process;
this.isShowPhrases = true;
this.eventType = eventType;
this.eventType = data.eventType;
},
showDisposal(eventType) {
this.isShowDisposal = true;

Loading…
Cancel
Save