Browse Source

feat:[模板管理][update]

ouqian
luojie 1 month ago
parent
commit
a401802c78
5 changed files with 98 additions and 86 deletions
  1. +9
    -8
      src/components/Template/CustomTable.vue
  2. +81
    -71
      src/components/Template/HandleFormItem.vue
  3. +3
    -0
      src/utils/index.js
  4. +4
    -7
      src/views/business/comps/template/formConfig/sp/SP0019.js
  5. +1
    -0
      src/views/business/comps/template/mixins/templateMixin.js

+ 9
- 8
src/components/Template/CustomTable.vue View File

@ -216,7 +216,8 @@ import { isShowOther } from "@/utils/formPackageCommon.js";
import { EventBus } from "@/utils/eventBus"; import { EventBus } from "@/utils/eventBus";
import { getuuid } from "@/utils/index.js"; import { getuuid } from "@/utils/index.js";
import { isRegent } from "@/utils/index.js"; import { isRegent } from "@/utils/index.js";
import moment from "moment";
import { isValueEmpty } from '@/utils/index.js';
import _ from "lodash"; import _ from "lodash";
export default { export default {
inject: ['templateFillType', 'getZdxgjl', 'updateZdxgjl'], inject: ['templateFillType', 'getZdxgjl', 'updateZdxgjl'],
@ -415,7 +416,7 @@ export default {
onCopy(rowIndex, col) { onCopy(rowIndex, col) {
if (col.copyFrom) { if (col.copyFrom) {
if (this.isValueEmpty(this.localDataSource[rowIndex][col.copyFrom])) {//
if (isValueEmpty(this.localDataSource[rowIndex][col.copyFrom])) {//
return return
} }
this.updateDataSourceByRowIndex(rowIndex, { [col.prop]: this.localDataSource[rowIndex][col.copyFrom] }, "clickable") this.updateDataSourceByRowIndex(rowIndex, { [col.prop]: this.localDataSource[rowIndex][col.copyFrom] }, "clickable")
@ -470,7 +471,7 @@ export default {
this.columns.forEach((col, colIndex) => { this.columns.forEach((col, colIndex) => {
if (col.headerSelectKey && col.headerOptions && col.fillType === this.templateFillType) { if (col.headerSelectKey && col.headerOptions && col.fillType === this.templateFillType) {
const headerValue = this.headerSelectFields[col.headerSelectKey]; const headerValue = this.headerSelectFields[col.headerSelectKey];
if (this.isValueEmpty(headerValue)) {
if (isValueEmpty(headerValue)) {
const errorItem = { const errorItem = {
rowIndex: -1, // rowIndex: -1, //
colIndex, colIndex,
@ -524,7 +525,7 @@ export default {
} }
} }
} else { } else {
if (this.isValueEmpty(mainValue) && !col.bodyDisabled && col.bodyType !== 'span' && col.bodyType !== 'button') {
if (isValueEmpty(mainValue) && !col.bodyDisabled && col.bodyType !== 'span' && col.bodyType !== 'button') {
const errorItem = { const errorItem = {
rowIndex, rowIndex,
colIndex, colIndex,
@ -540,7 +541,7 @@ export default {
if (col.bodySubKey && !col.bodySubDisabled && col.bodySubType !== 'span' && col.bodySubType !== "button") { if (col.bodySubKey && !col.bodySubDisabled && col.bodySubType !== 'span' && col.bodySubType !== "button") {
const subValue = row[col.bodySubKey]; const subValue = row[col.bodySubKey];
console.log(col, subValue, "subValue") console.log(col, subValue, "subValue")
if (this.isValueEmpty(subValue)) {
if (isValueEmpty(subValue)) {
const errorItem = { const errorItem = {
rowIndex, rowIndex,
colIndex, colIndex,
@ -563,7 +564,7 @@ export default {
return; return;
} }
const otherValue = row[col.otherCode]; const otherValue = row[col.otherCode];
if (this.isValueEmpty(otherValue)) {
if (isValueEmpty(otherValue)) {
const errorItem = { const errorItem = {
rowIndex, rowIndex,
colIndex, colIndex,
@ -636,7 +637,7 @@ export default {
this.columns.forEach((col, colIndex) => { this.columns.forEach((col, colIndex) => {
const currentValue = row[col.prop]; const currentValue = row[col.prop];
const compareToValue = row[col.compareTo]; const compareToValue = row[col.compareTo];
if (col.compareTo && !this.isValueEmpty(currentValue) && !this.isValueEmpty(compareToValue)) {
if (col.compareTo && !isValueEmpty(currentValue) && !isValueEmpty(compareToValue)) {
// compareTo // compareTo
if (!isEqual(currentValue, compareToValue)) { if (!isEqual(currentValue, compareToValue)) {
this.setOrangeBg(rowIndex, colIndex, col.prop, true); this.setOrangeBg(rowIndex, colIndex, col.prop, true);
@ -651,7 +652,7 @@ export default {
const currentValue = row[col.bodySubKey]; const currentValue = row[col.bodySubKey];
const compareToValue = row[col.bodySubCompareTo]; const compareToValue = row[col.bodySubCompareTo];
if (!this.isValueEmpty(currentValue) && !this.isValueEmpty(compareToValue)) {
if (!isValueEmpty(currentValue) && !isValueEmpty(compareToValue)) {
// compareTo // compareTo
if (!isEqual(currentValue, compareToValue)) { if (!isEqual(currentValue, compareToValue)) {
this.setOrangeBg(rowIndex, colIndex, col.bodySubKey, true); this.setOrangeBg(rowIndex, colIndex, col.bodySubKey, true);

+ 81
- 71
src/components/Template/HandleFormItem.vue View File

@ -34,16 +34,15 @@
</el-radio-group> </el-radio-group>
<div v-else-if="type === 'checkboxList'" class="flex1 checkbox-list-container" <div v-else-if="type === 'checkboxList'" class="flex1 checkbox-list-container"
:class="getFillTypeStyle() + (orangeBg ? ' orange-bg' : '')"> :class="getFillTypeStyle() + (orangeBg ? ' orange-bg' : '')">
<el-checkbox-group v-model="inputValue" @change="onInputChange">
<el-checkbox-group v-model="checkboxListValue.checkboxValues" @change="onCheckboxListChange">
<div v-for="option in item.options" :key="option.value" class="checkbox-item"> <div v-for="option in item.options" :key="option.value" class="checkbox-item">
<el-checkbox :label="option.value" :disabled="getDisabled()"> <el-checkbox :label="option.value" :disabled="getDisabled()">
{{ option.label }} {{ option.label }}
</el-checkbox> </el-checkbox>
<div v-if="option.otherCode && inputValue.includes(option.value)">
<el-input v-model="otherValues[option.otherCode]"
:placeholder="option.otherPlaceholder || '请输入'"
:class="{ 'error-border': isOtherInputError(option.otherCode) }" @blur="onBlur"
@input="onOtherInputChange(option.otherCode, $event)" />
<div v-if="isShowCheckboxListOther(option)">
<el-input v-model="checkboxListValue.otherValues[option.otherCode]"
:class="{ 'error-border': isOtherInputError(option.otherCode) }"
:placeholder="option.otherPlaceholder || '请输入'" @blur="onCheckboxListOtherBlur($event, option.otherCode)"/>
</div> </div>
</div> </div>
</el-checkbox-group> </el-checkbox-group>
@ -197,6 +196,8 @@ import moment from "moment";
import { deepClone } from "@/utils/index"; import { deepClone } from "@/utils/index";
import { getuuid } from "@/utils/index.js"; import { getuuid } from "@/utils/index.js";
import { getToken } from "@/utils/auth" import { getToken } from "@/utils/auth"
import { isValueEmpty } from '@/utils/index.js';
export default { export default {
inject: ['templateData', 'templateFillType', "getZdxgjl", "getFhyjjl", "updateZdxgjl", "replaceFhyjjl", "updateFhyjjl", "getFieldCheckObj", "updateFieldCheckObj"], inject: ['templateData', 'templateFillType', "getZdxgjl", "getFhyjjl", "updateZdxgjl", "replaceFhyjjl", "updateFhyjjl", "getFieldCheckObj", "updateFieldCheckObj"],
components: { components: {
@ -254,14 +255,14 @@ export default {
}, },
data() { data() {
let initialValue = this.value; let initialValue = this.value;
let initialOtherValues = {}, checkboxTagList = [], fqyqValue = {};
// checkboxListvalue
if (this.type === 'checkboxList' && this.value && typeof this.value === 'object') {
initialValue = this.value.checkboxValues || [];
initialOtherValues = this.value.otherValues || {};
} else if (this.type === 'checkboxList' && !Array.isArray(this.value)) {
initialValue = [];
let initialOtherValues = {}, checkboxTagList = [];
if(this.type === 'checkboxList' && !this.value) {
initialValue = {
checkboxValues: [],
otherValues: {}
};
} else if (this.type === 'checkboxTag' && Array.isArray(this.value)) { } else if (this.type === 'checkboxTag' && Array.isArray(this.value)) {
// checkboxTagvalue // checkboxTagvalue
checkboxTagList = this.value.map(tag => ({ checkboxTagList = this.value.map(tag => ({
@ -274,8 +275,6 @@ export default {
return { return {
inputValue: initialValue, inputValue: initialValue,
oldValue: initialValue, // oldValue: initialValue, //
otherValues: initialOtherValues, // checkboxListotherCode
oldOtherValues: { ...initialOtherValues }, // otherValues
showModal: false, // showModal: false, //
modificationRecords: [], // modificationRecords: [], //
modalTimer: null, // modalTimer: null, //
@ -289,6 +288,8 @@ export default {
oldCheckboxTagList: JSON.parse(JSON.stringify(checkboxTagList)), // checkboxTagList oldCheckboxTagList: JSON.parse(JSON.stringify(checkboxTagList)), // checkboxTagList
fqyqValue: initialValue, // fqyq fqyqValue: initialValue, // fqyq
oldFqyqValue: {...initialValue}, // fqyq oldFqyqValue: {...initialValue}, // fqyq
checkboxListValue: initialValue, // checkboxList
oldCheckboxListValue: JSON.parse(JSON.stringify(initialValue)), // checkboxList
uuid: getuuid(), // EventBus uuid: getuuid(), // EventBus
regentType: ['sj', 'gsp', 'mix', 'xj', 'xb', 'gyzj', 'mjy', 'yq', 'jcb', 'qxbd'], //// regentType: ['sj', 'gsp', 'mix', 'xj', 'xb', 'gyzj', 'mjy', 'yq', 'jcb', 'qxbd'], ////
selectRegentInfo: {},//// selectRegentInfo: {},////
@ -300,15 +301,17 @@ export default {
pendingUploadFile: null, // pendingUploadFile: null, //
pendingRemoveFile: null, // pendingRemoveFile: null, //
currentTagIndex:-1,//checkboxTag currentTagIndex:-1,//checkboxTag
currentFqyqType:'',//fqyq
currentHandleType:'',//
currentOtherCode:'',//otherCode
} }
}, },
watch: { watch: {
value(newVal) { value(newVal) {
console.log(newVal, "newVal")
if (this.type === 'checkboxList' && newVal && typeof newVal === 'object') { if (this.type === 'checkboxList' && newVal && typeof newVal === 'object') {
this.inputValue = newVal.checkboxValues || [];
this.otherValues = newVal.otherValues || {};
this.checkboxListValue = {
checkboxValues: newVal.checkboxValues || [],
otherValues: newVal.otherValues || {}
};
} else if (this.type === 'checkboxTag' && Array.isArray(newVal)) { } else if (this.type === 'checkboxTag' && Array.isArray(newVal)) {
// checkboxTagvalue // checkboxTagvalue
this.checkboxTagList = newVal.map(tag => ({ this.checkboxTagList = newVal.map(tag => ({
@ -323,7 +326,7 @@ export default {
subRadio: newVal.subRadio || '' subRadio: newVal.subRadio || ''
}; };
} else { } else {
this.inputValue = this.type === 'checkboxList' && !Array.isArray(newVal) ? [] : newVal;
this.inputValue = newVal;
} }
} }
}, },
@ -800,49 +803,17 @@ export default {
// //
onInputChange(val) { onInputChange(val) {
let value = val !== undefined ? val : this.inputValue; let value = val !== undefined ? val : this.inputValue;
// checkboxListcheckboxValuesotherValues
if (this.type === 'checkboxList') {
// checkbox
if (this.oldValue && Array.isArray(this.oldValue)) {
const uncheckedValues = this.oldValue.filter(oldVal => !this.inputValue.includes(oldVal));
// checkboxotherValues
if (uncheckedValues.length > 0) {
this.item.options.forEach(option => {
if (uncheckedValues.includes(option.value) && option.otherCode) {
this.$delete(this.otherValues, option.otherCode);
}
});
}
}
value = {
checkboxValues: this.inputValue,
otherValues: this.otherValues
};
if (val) {
this.onCommonHandleSaveRecord();
}
}
this.$emit('input', value); this.$emit('input', value);
this.$emit('change', value); this.$emit('change', value);
// //
const isEmpty = this.isValueEmpty(value);
const isEmpty = isValueEmpty(value);
if (this.error && !isEmpty) { if (this.error && !isEmpty) {
this.$emit('update:error', false); this.$emit('update:error', false);
} else if (!this.error && isEmpty) { } else if (!this.error && isEmpty) {
this.$emit('update:error', true); this.$emit('update:error', true);
} }
}, },
// checkboxListotherCode
onOtherInputChange(code, value) {
this.otherValues[code] = value;
this.onInputChange();
},
// checkboxTagcheckbox // checkboxTagcheckbox
onCheckboxTagChange(tagIndex, e) { onCheckboxTagChange(tagIndex, e) {
this.currentTagIndex = tagIndex; this.currentTagIndex = tagIndex;
@ -850,6 +821,20 @@ export default {
this.emitCheckboxTagValue(); this.emitCheckboxTagValue();
this.onCommonHandleSaveRecord(); this.onCommonHandleSaveRecord();
}, },
// checkboxList
isShowCheckboxListOther(option) {
const {checkboxValues } = this.checkboxListValue
if (!checkboxValues) {
return false;
}
return option.otherCode && checkboxValues.includes(option.value);
},
// checkboxListcheckbox
onCheckboxListChange(val) {
this.currentHandleType = 'checkboxListValue';
this.checkboxListValue.checkboxValues = val;
this.onCommonHandleSaveRecord();
},
// tag // tag
onTagBlur(tagIndex) { onTagBlur(tagIndex) {
@ -875,14 +860,14 @@ export default {
// fqyq radio // fqyq radio
onFqyqRadioChange(val, radioType) { onFqyqRadioChange(val, radioType) {
this.fqyqValue[radioType] = val; this.fqyqValue[radioType] = val;
this.currentFqyqType = radioType;
this.currentHandleType = radioType;
this.onCommonHandleSaveRecord(); this.onCommonHandleSaveRecord();
}, },
// fqyq // fqyq
onFqyqInputBlur(e) { onFqyqInputBlur(e) {
this.fqyqValue.inputValue = e.target.value; this.fqyqValue.inputValue = e.target.value;
this.currentFqyqType = 'inputValue';
this.currentHandleType = 'inputValue';
this.onCommonHandleSaveRecord(this.fqyqValue.inputValue); this.onCommonHandleSaveRecord(this.fqyqValue.inputValue);
}, },
@ -890,6 +875,13 @@ export default {
onBlur(e) { onBlur(e) {
this.onCommonHandleSaveRecord(e.target.value); this.onCommonHandleSaveRecord(e.target.value);
}, },
// checkboxList
onCheckboxListOtherBlur(e, otherCode) {
this.currentHandleType = "checkboxListOther";
this.currentOtherCode = otherCode;
this.checkboxListValue.otherValues[otherCode] = e.target.value;
this.onCommonHandleSaveRecord(e.target.value);
},
// question // question
onClickQuestion() { onClickQuestion() {
const { templateFillType } = this; const { templateFillType } = this;
@ -918,6 +910,15 @@ export default {
this.visible = true; this.visible = true;
} }
}, },
getCheckboxListInfo(){
const { otherValues,checkboxValues } = this.checkboxListValue;
const { otherValues: oldOtherValues,checkboxValues: oldCheckboxValues } = this.oldCheckboxListValue;
const o = {
"checkboxListValue":{oldValue:oldCheckboxValues,newValue:checkboxValues,des:""},
"checkboxListOther":{oldValue:oldOtherValues[this.currentOtherCode],newValue:otherValues[this.currentOtherCode],des:""},
}
return o[this.currentHandleType];
},
getFqyqInfo(){ getFqyqInfo(){
const { mainRadio,inputValue,subRadio } = this.fqyqValue; const { mainRadio,inputValue,subRadio } = this.fqyqValue;
const { mainRadio: oldMainRadio,inputValue: oldInputValue,subRadio: oldSubRadio } = this.oldFqyqValue; const { mainRadio: oldMainRadio,inputValue: oldInputValue,subRadio: oldSubRadio } = this.oldFqyqValue;
@ -926,10 +927,10 @@ export default {
"inputValue":{oldValue:oldInputValue,newValue:inputValue,des:""}, "inputValue":{oldValue:oldInputValue,newValue:inputValue,des:""},
"subRadio":{oldValue:oldSubRadio,newValue:subRadio,des:"是否在规定时间完成:"} "subRadio":{oldValue:oldSubRadio,newValue:subRadio,des:"是否在规定时间完成:"}
} }
return o[this.currentFqyqType];
return o[this.currentHandleType];
}, },
async onCommonHandleSaveRecord(val) { async onCommonHandleSaveRecord(val) {
const isEmpty = this.isValueEmpty(this.inputValue);
const isEmpty = isValueEmpty(this.inputValue);
if (this.error && !isEmpty) { if (this.error && !isEmpty) {
this.$emit('update:error', false); this.$emit('update:error', false);
} else if (!this.error && isEmpty) { } else if (!this.error && isEmpty) {
@ -974,23 +975,25 @@ export default {
// //
let isSame = true, isOldValueEmpty = true; let isSame = true, isOldValueEmpty = true;
const { currentHandleType } = this;
// checkboxListotherValues // checkboxListotherValues
if (this.type === 'checkboxList' && this.otherValues) {
isSame = this.isEqual(this.oldOtherValues, this.otherValues);
isOldValueEmpty = this.isValueEmpty(this.oldOtherValues);
if (this.type === 'checkboxList') {
const current = this.getCheckboxListInfo();
isSame = this.isEqual(current.oldValue,current.newValue);
isOldValueEmpty = isValueEmpty(current.oldValue);
} else if (this.type === "checkboxTag") { } else if (this.type === "checkboxTag") {
// checkboxTagtagIndex // checkboxTagtagIndex
const currentTag = this.checkboxTagList[this.currentTagIndex]; const currentTag = this.checkboxTagList[this.currentTagIndex];
const oldTag = this.oldCheckboxTagList[this.currentTagIndex] || {}; const oldTag = this.oldCheckboxTagList[this.currentTagIndex] || {};
isSame = this.isEqual(oldTag.checked, currentTag.checked); isSame = this.isEqual(oldTag.checked, currentTag.checked);
isOldValueEmpty = this.isValueEmpty(oldTag.checked);
isOldValueEmpty = isValueEmpty(oldTag.checked);
} else if (this.type === "fqyq") { } else if (this.type === "fqyq") {
const current = this.getFqyqInfo(); const current = this.getFqyqInfo();
isSame = this.isEqual(current.oldValue,current.newValue); isSame = this.isEqual(current.oldValue,current.newValue);
isOldValueEmpty = this.isValueEmpty(current.oldValue);
isOldValueEmpty = isValueEmpty(current.oldValue);
} else { } else {
isSame = this.isEqual(this.oldValue, this.inputValue) isSame = this.isEqual(this.oldValue, this.inputValue)
isOldValueEmpty = this.isValueEmpty(this.oldValue);
isOldValueEmpty = isValueEmpty(this.oldValue);
} }
console.log(isSame,isOldValueEmpty,this.fqyqValue,this.oldFqyqValue,"isSame") console.log(isSame,isOldValueEmpty,this.fqyqValue,this.oldFqyqValue,"isSame")
if (isSame) { if (isSame) {
@ -1010,8 +1013,8 @@ export default {
let oldValue = this.oldValue; let oldValue = this.oldValue;
if (this.type === 'checkboxList') { if (this.type === 'checkboxList') {
oldValue = { oldValue = {
checkboxValues: oldValue.checkboxValues || oldValue,
otherValues: this.oldOtherValues
checkboxValues: this.oldCheckboxListValue.checkboxValues,
otherValues: this.oldCheckboxListValue.otherValues
}; };
} else if (this.type === "checkboxTag") { } else if (this.type === "checkboxTag") {
// checkboxTag退tagIndex // checkboxTag退tagIndex
@ -1061,6 +1064,11 @@ export default {
recordOldVlaue = `${current.des+current.oldValue}`; recordOldVlaue = `${current.des+current.oldValue}`;
recordValue = `${current.des+current.newValue}`; recordValue = `${current.des+current.newValue}`;
isModify = !!this.oldFqyqValue.mainRadio isModify = !!this.oldFqyqValue.mainRadio
}else if(this.type === "checkboxList"){
const current = this.getCheckboxListInfo();
recordOldVlaue = `${current.des+current.oldValue}`;
recordValue = `${current.des+current.newValue}`;
isModify = !!current.oldValue;
} }
const record = { const record = {
...baseInfo, ...baseInfo,
@ -1081,8 +1089,7 @@ export default {
// oldValueoldOtherValues // oldValueoldOtherValues
if (this.type === 'checkboxList') { if (this.type === 'checkboxList') {
this.oldValue = [...this.inputValue];
this.oldOtherValues = { ...this.otherValues };
this.oldCheckboxListValue = JSON.parse(JSON.stringify(this.checkboxListValue));
} else if (this.type === "checkboxTag") { } else if (this.type === "checkboxTag") {
// checkboxTagtagIndex // checkboxTagtagIndex
if (this.currentTagIndex >= 0 && this.currentTagIndex < this.checkboxTagList.length) { if (this.currentTagIndex >= 0 && this.currentTagIndex < this.checkboxTagList.length) {
@ -1098,8 +1105,8 @@ export default {
let value = this.inputValue; let value = this.inputValue;
if (this.type === 'checkboxList') { if (this.type === 'checkboxList') {
value = { value = {
checkboxValues: this.inputValue,
otherValues: this.otherValues
checkboxValues: this.checkboxListValue.checkboxValues,
otherValues: this.checkboxListValue.otherValues
}; };
} else if (this.type === "checkboxTag") { } else if (this.type === "checkboxTag") {
value = [...this.checkboxTagList]; value = [...this.checkboxTagList];
@ -1178,6 +1185,9 @@ export default {
if (Array.isArray(value) && value.length === 0) { if (Array.isArray(value) && value.length === 0) {
return true; return true;
} }
if (Object.keys(value).length === 0) {
return true;
}
return false; return false;
}, },
// checkboxListotherCode // checkboxListotherCode
@ -1186,7 +1196,7 @@ export default {
return false; return false;
} }
// otherCode // otherCode
return this.isValueEmpty(this.otherValues[otherCode]);
return isValueEmpty(this.otherValues[otherCode]);
}, },
handleClickable(item, event) { handleClickable(item, event) {
if (this.templateFillType !== 'actFill') { if (this.templateFillType !== 'actFill') {

+ 3
- 0
src/utils/index.js View File

@ -441,6 +441,9 @@ export function isValueEmpty(value) {
if (Array.isArray(value) && value.length === 0) { if (Array.isArray(value) && value.length === 0) {
return true return true
} }
if (Object.keys(value).length === 0) {
return true;
}
return false return false
} }

+ 4
- 7
src/views/business/comps/template/formConfig/sp/SP0019.js View File

@ -93,18 +93,15 @@ export const getYqColumns = ($this) => {
} }
//溶液 //溶液
export const getRyColumns = () => {
export const getRyColumns = ($this) => {
return [ return [
{ {
label: '溶液类型', label: '溶液类型',
prop: "rylx", prop: "rylx",
bodyType: "select", bodyType: "select",
bodyOptions: [
{
label: '溶液',
value: '溶液'
},
],
otherCode:'rylxOther',
width:380,
bodyOptions: $this.getDictOptions('business_rylx'),
bodyFillType: 'preFill', bodyFillType: 'preFill',
}, },
{ {

+ 1
- 0
src/views/business/comps/template/mixins/templateMixin.js View File

@ -39,6 +39,7 @@ export default {
'business_sydd', // 毒理-Ames实验地点 'business_sydd', // 毒理-Ames实验地点
'business_dl_ameswrqk', // 毒理-Ames污染情况 'business_dl_ameswrqk', // 毒理-Ames污染情况
'business_dl_amescltj', // 毒理-Ames处理条件 'business_dl_amescltj', // 毒理-Ames处理条件
'business_rylx', // 溶液类型
], ],
props: { props: {
templateData: { templateData: {

Loading…
Cancel
Save