路由代码优化

抽取列表页
master
toesbieya 6 years ago
parent 2d9609f04b
commit e565dbb106
  1. 8
      vue/src/component/RegionSelector/Tab/index.vue
  2. 4
      vue/src/component/UploadFile/index.vue
  3. 4
      vue/src/globalMethod/Guide/main.vue
  4. 28
      vue/src/mixin/docTableMixin.js
  5. 4
      vue/src/router/define.js
  6. 5
      vue/src/router/index.js
  7. 7
      vue/src/router/module/devRoot/child/uploadFileDev.js
  8. 11
      vue/src/router/module/devRoot/index.js
  9. 3
      vue/src/router/module/index.js
  10. 87
      vue/src/router/util.js
  11. 19
      vue/src/store/module/resource.js
  12. 16
      vue/src/util/index.js
  13. 3
      vue/src/util/tree.js
  14. 54
      vue/src/view/app/common/ListPage/index.vue
  15. 9
      vue/src/view/dev/uploadFileDev.vue
  16. 31
      vue/src/view/purchase/inbound/indexPage.vue
  17. 33
      vue/src/view/purchase/order/indexPage.vue
  18. 38
      vue/src/view/sell/order/indexPage.vue
  19. 38
      vue/src/view/sell/outbound/indexPage.vue

@ -59,7 +59,7 @@
</template>
<script>
import {debounce} from "@/util"
import {debounce, deepClone} from "@/util"
import {createLimitTree, getNodeId} from "@/util/tree"
import {store, init} from '../store'
import common from '../mixin'
@ -174,7 +174,7 @@ export default {
setTabs(item, level) {
const prev = this.selected.slice(0, level - 1)
prev.push(item)
const next = JSON.parse(JSON.stringify(DEFAULT_TABS))
const next = deepClone(DEFAULT_TABS)
if (level === 1) {
//-1''tab
@ -239,7 +239,7 @@ export default {
removeAll() {
this.realMaxLevel = this.maxLevel
this.selected = JSON.parse(JSON.stringify(DEFAULT_TABS))
this.selected = deepClone(DEFAULT_TABS)
this.currentLevel = 1
this.searchText = ''
this.setItems(this.currentLevel)
@ -284,7 +284,7 @@ export default {
created() {
this.search = debounce(this.search, 200)
this.selected = JSON.parse(JSON.stringify(DEFAULT_TABS))
this.selected = deepClone(DEFAULT_TABS)
},
mounted() {

@ -58,7 +58,7 @@
import axios from 'axios'
import {deleteUpload} from '@/api/file'
import {file as fileConfig} from '@/config'
import {debounce, isEmpty} from '@/util'
import {debounce, isEmpty, deepClone} from '@/util'
import {elError} from "@/util/message"
import {isImage, isDoc, isPdf, isPpt, isRar, isXls, isTxt, isZip} from "@/util/validate"
import {preview, download, upload, autoCompleteUrl} from "@/util/file"
@ -129,7 +129,7 @@ export default {
return {...file, url: getCoverImage(downloadUrl, file.name), downloadUrl}
})]
}
this.tempData = JSON.parse(JSON.stringify(this.data))
this.tempData = deepClone(this.data)
}
}
},

@ -37,7 +37,7 @@
* 由driver.js改造而来
* vue里交互型的导航不太行太多东西做不到换成引导手册吧
*/
import {debounce, isEmpty} from "@/util"
import {debounce, isEmpty, deepClone} from "@/util"
import {addHighlightClasses, getCalculatedPosition, inMainPage, jump, removeHighlightClasses} from "./utils"
export default {
@ -101,7 +101,7 @@ export default {
else if (index === 'first') index = 0
if (index > this.steps.length - 1 || !this.steps[index]) {
console.error('error index:', index, 'error steps:', JSON.parse(JSON.stringify(this.steps)))
console.error('error index:', index, 'error steps:', deepClone(this.steps))
throw new Error('起始步骤有误')
}

@ -1,5 +1,7 @@
import tableMixin from '@/mixin/tablePageMixin'
import SearchFormItem from "@/component/form/Search/item"
import LinerProgress from '@/component/LinerProgress'
import ListPage from '@/view/app/common/ListPage'
import {isEmpty} from "@/util"
import {elConfirm, elError, elSuccess} from "@/util/message"
import {auth} from "@/util/auth"
@ -33,7 +35,7 @@ export const commonMethods = {
export default {
mixins: [tableMixin],
components: {LinerProgress},
components: {SearchFormItem, LinerProgress, ListPage},
data() {
return {
@ -66,6 +68,30 @@ export default {
canExport() {
return auth(this.api.exportExcel.url)
},
buttonGroup() {
return [
{icon: 'el-icon-search', type: 'primary', e: this.search, content: '查 询'},
this.canAdd && {icon: 'el-icon-plus', type: 'primary', e: this.add, content: '添 加'},
{icon: 'el-icon-view', type: 'primary', e: this.see, content: '查 看'},
this.canUpdate && {icon: 'el-icon-edit', type: 'primary', e: this.edit, content: '编 辑'},
this.canDel && {icon: 'el-icon-delete', type: 'danger', e: this.del, content: '删 除'},
this.canExport && {icon: 'el-icon-download', type: 'info', e: this.downloadExcel, content: '导 出'}
]
},
listPageConfig() {
return {
loading: this.config.loading,
tableConfig: {
props: {data: this.tableData},
on: {'row-click': this.rowClick, 'expand-change': this.getSubList}
},
paginationConfig: {
props: {model: this.searchForm},
on: {'current-change': this.pageChange}
}
}
}
},
methods: {

@ -5,10 +5,10 @@ import Page403 from "@/view/app/403"
import Page404 from "@/view/app/404"
import Page500 from "@/view/app/500"
import routes from './module'
import {parseRoutes, stringifyRoutes} from "./util"
import {deepClone} from "@/util"
export function getDynamicRoutes() {
return parseRoutes(stringifyRoutes(routes))
return deepClone(routes)
}
//系统自有路由

@ -14,7 +14,8 @@
import Vue from 'vue'
import Router from 'vue-router'
import {route as routeConfig} from '@/config'
import {stringifyRoutes, parseRoutes, generateRoutes} from './util'
import {deepClone} from "@/util"
import {generateRoutes} from './util'
import defaultRoutes from './define'
import registerGuardian from './guardian'
@ -32,7 +33,7 @@ registerGuardian(router)
export default router
export function addDynamicRoutes(jsonTree) {
jsonTree = parseRoutes(stringifyRoutes(jsonTree))
jsonTree = deepClone(jsonTree)
const endRoute = {path: '*', redirect: '/404'}
router.addRoutes([...generateRoutes(jsonTree), endRoute])
}

@ -0,0 +1,7 @@
import component from '@/view/dev/uploadFileDev'
export default {
path: 'uploadFile',
component,
meta: {title: '上传'}
}

@ -0,0 +1,11 @@
/*顶级菜单:开发用的页面*/
import {context2array} from '@/router/util'
const files = require.context('./child', false, /\.js$/)
export default {
path: '/dev',
component: {template: `<div><div>开发专用页面</div><router-view/></div>`},
meta: {title: '开发专用页面', noAuth: true, noCache: true, alwaysShow: true},
children: context2array(files)
}

@ -1,5 +1,6 @@
import defaultRoot from './defaultRoot'
import exampleRoot from './exampleRoot'
import devRoot from './devRoot'
import docRoot from './docRoot'
export default [defaultRoot, exampleRoot, docRoot]
export default [defaultRoot, exampleRoot, devRoot, docRoot]

@ -2,12 +2,12 @@ import Layout from '@/layout'
import SkeletonPage from '@/view/app/common/SkeletonPage'
import Page500 from '@/view/app/500'
import {isEmpty} from "@/util"
import {isExternal} from "@/util/validate"
import {isExternal, isString} from "@/util/validate"
//json字符串转路由
export function parseRoutes(str) {
//json字符串转路由配置
export function str2routeConfig(str) {
const parseOpt = (key, value) => {
if (key === 'dynamicTitle' && typeof value === 'string') {
if (key === 'dynamicTitle' && isString(value)) {
return isEmpty(value) ? undefined : eval(`(${value})`)
}
return value
@ -15,17 +15,6 @@ export function parseRoutes(str) {
return JSON.parse(str, parseOpt)
}
//路由转json字符串
export function stringifyRoutes(routes) {
const stringifyOpt = (key, value) => {
if (key === 'dynamicTitle' && typeof value === 'function') {
return value.toString()
}
return value
}
return JSON.stringify(routes, stringifyOpt)
}
//根据json数组生成路由
export function generateRoutes(jsonTree) {
//将深度为2的节点合并为由其最末子节点组成的数组
@ -56,59 +45,68 @@ export function generateRoutes(jsonTree) {
})
}
//数据转换,同时剔除不合法的路由(外链、无子节点且无component且非iframe)
//数据转换,同时剔除不合法的路由
function transform(routes, depth = 1) {
if (!Array.isArray(routes)) return
for (let i = routes.length - 1; i >= 0; i--) {
const {path, component, children, meta = {}} = routes[i]
if (isEmpty(component)) {
//剔除外链
if (isExternal(path)) {
//剔除外链节点以及无component且非iframe的节点
if (isExternal(path) || !component && !meta.iframe) {
routes.splice(i, 1)
continue
}
delete routes[i].component
//清理一些空属性,避免vue-router出错
['name', 'component', 'meta'].forEach(key => {
if (key in routes[i] && isEmpty(routes[i][key])) {
delete routes[i][key]
}
else {
//设置路由组件
if (component.toUpperCase() === 'LAYOUT') {
})
//设置路由组件,当component是vue组件声明对象时,不会进行懒加载
if (component) {
if (isString(component) && component.toUpperCase() === 'LAYOUT') {
routes[i].component = Layout
}
else routes[i].component = lazyLoadView(component)
else if (isString(component) || typeof component === 'function') {
routes[i].component = lazyLoadView(component)
}
if (!children || children.length <= 0) {
//末级节点统一设置props=true,省的动态路由还要手动加
routes[i].props = true
}
else {
//先尝试递归
transform(children, depth + 1)
//如果子级被清空,并且无component且非iframe
if (children.length <= 0 && !meta.iframe) {
routes.splice(i, 1)
continue
//叶子节点统一设置props = true,省的动态路由还要手动加
if (!children || children.length <= 0) {
routes[i].props = true
}
//设置根节点的redirect
if (depth === 1) {
let rootPath = path
if (!rootPath.endsWith('/')) rootPath += '/'
else if (depth === 1) {
const rootPath = path.endsWith('/') ? path : `${path}/`
routes[i].redirect = `${rootPath}${children[0].path}`
}
}
}
}
//路由页面懒加载
function lazyLoadView(path) {
if (path.endsWith('/')) path += 'index'
else if (path.endsWith('Page')) path = path.replace('Page', '')
else path = path.replace('Page.vue', '')
//路由页面懒加载,入参可以是字符串、返回值为Promise的函数
function lazyLoadView(component) {
if (isString(component)) {
if (component.endsWith('/')) {
component += 'index'
}
else if (component.endsWith('Page')) {
component = component.replace('Page', '')
}
else {
component = component.replace('Page.vue', '')
}
}
//这里注意一点,如果设置了超时时间,那么超时后只能刷新整个页面才能重新加载该组件
const AsyncHandler = () => ({
component: import(`@/view/${path}Page.vue`),
component: typeof component === 'function' ? component() : import(`@/view/${component}Page.vue`),
loading: SkeletonPage,
error: Page500,
delay: 200,
@ -116,6 +114,7 @@ export function generateRoutes(jsonTree) {
})
return () => Promise.resolve({
abstract: true,
functional: true,
render(h, {data, children}) {
return h(AsyncHandler, data, children)

@ -3,9 +3,9 @@ import {route as routeConfig} from '@/config'
import {mutations as appMutations} from "@/layout/store/app"
import {addDynamicRoutes} from '@/router'
import {getDynamicRoutes} from '@/router/define'
import {parseRoutes, metaExtend} from "@/router/util"
import {str2routeConfig, metaExtend} from "@/router/util"
import {getAll} from "@/api/system/resource"
import {isEmpty} from "@/util"
import {isEmpty, deepClone} from "@/util"
import {needAuth, auth} from "@/util/auth" // 此处存在循环引用?
import {createTree} from "@/util/tree"
import {isExternal} from "@/util/validate"
@ -70,22 +70,15 @@ function transformOriginRouteData(data) {
//未开启后端动态路由功能时,返回前端预设的静态路由
if (!routeConfig.useBackendDataAsRoute) return getDynamicRoutes()
data = JSON.parse(JSON.stringify(data)).filter(i => {
data = deepClone(data).filter(i => {
//过滤掉数据接口或者未启用的项
if (i.type === 3 || !i.enable) {
return false
}
//清理一些空属性,避免vue-router出错
['name', 'component', 'meta'].forEach(key => {
if (key in i && isEmpty(i[key])) {
delete i[key]
}
})
//将字符串形式的meta转为合法的js对象
if (typeof i.meta === 'string') {
i.meta = parseRoutes(i.meta)
i.meta = str2routeConfig(i.meta)
}
return true
@ -96,7 +89,7 @@ function transformOriginRouteData(data) {
//获取经过权限控制后的菜单
function getAuthorizedMenus({resources, admin}, menus) {
menus = JSON.parse(JSON.stringify(menus))
menus = deepClone(menus)
clean(menus)
addFullPath(menus)
filter(menus, i => !needAuth(i) || auth(i.fullPath))
@ -164,7 +157,7 @@ function generateResourceMap(resources) {
return {}
}
resources = JSON.parse(JSON.stringify(resources))
resources = deepClone(resources)
const result = {}

@ -199,3 +199,19 @@ export function bindThis(obj, root = obj) {
return obj
}
export function deepClone(source) {
if (source === null || typeof source !== 'object' || source instanceof Promise) {
return source
}
if (Array.isArray(source)) {
return source.map(i => deepClone(i))
}
else {
return Object.keys(source).reduce((obj, key) => {
obj[key] = deepClone(source[key])
return obj
}, {})
}
}

@ -1,3 +1,4 @@
import {deepClone} from "@/util/index"
import {createWorker} from '@/util/worker'
const DEFAULT_PROPS = {id: 'id', pid: 'pid', children: 'children', leafHasChildren: true}
@ -49,7 +50,7 @@ export function createLimitTree(full, limit) {
return m
}, {})
full = JSON.parse(JSON.stringify(full))
full = deepClone(full)
const result = shakeTree(full, node => {
const value = map[node.id]

@ -0,0 +1,54 @@
<script type="text/jsx">
import AbstractTable from "@/component/abstract/Table"
import AbstractPagination from "@/component/abstract/Pagination"
import SearchForm from "@/component/form/Search"
const renderSearchForm = (h, slot) => {
return slot && <SearchForm>{slot()}</SearchForm>
}
const renderButtons = (h, buttons) => {
return (
<el-row class="button-group">
{buttons.map(button => {
if (!button) return
const {icon, type, content, e} = button
return <el-button icon={icon} size="small" type={type} on-click={e}>{content}</el-button>
})}
</el-row>
)
}
const renderList = (h, {loading, tableConfig, tableColumn, paginationConfig}) => {
return (
<el-row v-loading={loading} class="table-container">
<AbstractTable {...tableConfig}>{tableColumn()}</AbstractTable>
<AbstractPagination {...paginationConfig}/>
</el-row>
)
}
export default {
name: "ListPage",
functional: true,
props: {
loading: Boolean,
buttons: {type: Array, default: () => []},
list: Object
},
render(h, context) {
const {props: {loading, buttons, list}, scopedSlots} = context
return (
<el-card v-loading={loading}>
{renderSearchForm(h, scopedSlots.searchForm)}
{renderButtons(h, buttons)}
{renderList(h, {...list, tableColumn: scopedSlots.tableColumn})}
</el-card>
)
}
}
</script>

@ -0,0 +1,9 @@
<template>
<div>haha</div>
</template>
<script>
export default {
name: "uploadFileDev"
}
</script>

@ -1,6 +1,6 @@
<template>
<el-card v-loading="config.operating">
<search-form>
<list-page :loading="config.operating" :buttons="buttonGroup" :list="listPageConfig">
<template v-slot:searchForm>
<search-form-item label="单 号">
<el-input v-model="searchForm.idFuzzy" clearable maxlength="50"/>
</search-form-item>
@ -38,20 +38,8 @@
<el-option :value="2" label="已审核"/>
</el-select>
</search-form-item>
</search-form>
<el-row class="button-group">
<el-button icon="el-icon-search" size="small" type="primary" @click="search"> </el-button>
<el-button v-if="canAdd" icon="el-icon-plus" size="small" type="primary" @click="add"> </el-button>
<el-button icon="el-icon-view" size="small" type="primary" @click="see"> </el-button>
<el-button v-if="canUpdate" icon="el-icon-edit" size="small" type="primary" @click="edit"> </el-button>
<el-button v-if="canDel" icon="el-icon-delete" size="small" type="danger" @click="del"> </el-button>
<el-button v-if="canExport" icon="el-icon-download" size="small" type="info" @click="downloadExcel">
</el-button>
</el-row>
<el-row v-loading="config.loading" class="table-container">
<abstract-table :data="tableData" @row-click="rowClick" @expand-change="getSubList">
</template>
<template v-slot:tableColumn>
<el-table-column align="center" type="expand">
<template v-slot="{row}">
<liner-progress :show="row._loading"/>
@ -86,17 +74,12 @@
{{ getStatus(row.status) }}
</template>
</el-table-column>
</abstract-table>
<abstract-pagination :model="searchForm" @current-change="pageChange"/>
</el-row>
</el-card>
</template>
</list-page>
</template>
<script>
import docTableMixin from '@/mixin/docTableMixin'
import SearchForm from '@/component/form/Search'
import SearchFormItem from "@/component/form/Search/item"
import {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel} from "@/api/doc/purchase/inbound"
export default {
@ -104,8 +87,6 @@ export default {
mixins: [docTableMixin],
components: {SearchForm, SearchFormItem},
data() {
this.api = {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel}
return {

@ -1,6 +1,6 @@
<template>
<el-card v-loading="config.operating">
<search-form>
<list-page :loading="config.operating" :buttons="buttonGroup" :list="listPageConfig">
<template v-slot:searchForm>
<search-form-item label="单 号">
<el-input v-model="searchForm.idFuzzy" clearable maxlength="50"/>
</search-form-item>
@ -54,20 +54,8 @@
value-format="timestamp"
/>
</search-form-item>
</search-form>
<el-row class="button-group">
<el-button icon="el-icon-search" size="small" type="primary" @click="search"> </el-button>
<el-button v-if="canAdd" icon="el-icon-plus" size="small" type="primary" @click="add"> </el-button>
<el-button icon="el-icon-view" size="small" type="primary" @click="see"> </el-button>
<el-button v-if="canUpdate" icon="el-icon-edit" size="small" type="primary" @click="edit"> </el-button>
<el-button v-if="canDel" icon="el-icon-delete" size="small" type="danger" @click="del"> </el-button>
<el-button v-if="canExport" icon="el-icon-download" size="small" type="info" @click="downloadExcel">
</el-button>
</el-row>
<el-row v-loading="config.loading" class="table-container">
<abstract-table :data="tableData" @row-click="rowClick" @expand-change="getSubList">
</template>
<template v-slot:tableColumn>
<el-table-column align="center" type="expand">
<template v-slot="{row}">
<liner-progress :show="row._loading"/>
@ -114,17 +102,12 @@
<el-table-column align="center" label="完成时间" width="150">
<template v-slot="{row}">{{ row.ftime | timestamp2Date }}</template>
</el-table-column>
</abstract-table>
<abstract-pagination :model="searchForm" @current-change="pageChange"/>
</el-row>
</el-card>
</template>
</list-page>
</template>
<script>
<script type="text/jsx">
import docTableMixin from '@/mixin/docTableMixin'
import SearchForm from '@/component/form/Search'
import SearchFormItem from "@/component/form/Search/item"
import {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel} from "@/api/doc/purchase/order"
export default {
@ -132,8 +115,6 @@ export default {
mixins: [docTableMixin],
components: {SearchForm, SearchFormItem},
data() {
this.api = {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel}
return {

@ -1,6 +1,6 @@
<template>
<el-card v-loading="config.operating">
<search-form>
<list-page :loading="config.operating" :buttons="buttonGroup" :list="listPageConfig">
<template v-slot:searchForm>
<search-form-item label="单 号">
<el-input v-model="searchForm.idFuzzy" clearable maxlength="50"/>
</search-form-item>
@ -54,27 +54,8 @@
value-format="timestamp"
/>
</search-form-item>
</search-form>
<el-row class="button-group">
<el-button icon="el-icon-search" size="small" type="primary" @click="search"> </el-button>
<el-button v-if="canAdd" icon="el-icon-plus" size="small" type="primary" @click="add"> </el-button>
<el-button icon="el-icon-view" size="small" type="primary" @click="see"> </el-button>
<el-button v-if="canUpdate" icon="el-icon-edit" size="small" type="primary" @click="edit"> </el-button>
<el-button v-if="canDel" icon="el-icon-delete" size="small" type="danger" @click="del"> </el-button>
<el-button
v-if="canExport"
icon="el-icon-download"
size="small"
type="info"
@click="downloadExcel"
>
</el-button>
</el-row>
<el-row v-loading="config.loading" class="table-container">
<abstract-table :data="tableData" @row-click="rowClick" @expand-change="getSubList">
</template>
<template v-slot:tableColumn>
<el-table-column align="center" type="expand">
<template v-slot="{row}">
<liner-progress :show="row._loading"/>
@ -121,17 +102,12 @@
<el-table-column align="center" label="完成时间" width="150">
<template v-slot="{row}">{{ row.ftime | timestamp2Date }}</template>
</el-table-column>
</abstract-table>
<abstract-pagination :model="searchForm" @current-change="pageChange"/>
</el-row>
</el-card>
</template>
</list-page>
</template>
<script>
import docTableMixin from '@/mixin/docTableMixin'
import SearchForm from '@/component/form/Search'
import SearchFormItem from "@/component/form/Search/item"
import {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel} from "@/api/doc/sell/order"
export default {
@ -139,8 +115,6 @@ export default {
mixins: [docTableMixin],
components: {SearchForm, SearchFormItem},
data() {
this.api = {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel}
return {

@ -1,6 +1,6 @@
<template>
<el-card v-loading="config.operating">
<search-form>
<list-page :loading="config.operating" :buttons="buttonGroup" :list="listPageConfig">
<template v-slot:searchForm>
<search-form-item label="单 号">
<el-input v-model="searchForm.idFuzzy" clearable maxlength="50"/>
</search-form-item>
@ -38,27 +38,8 @@
<el-option :value="2" label="已审核"/>
</el-select>
</search-form-item>
</search-form>
<el-row class="button-group">
<el-button icon="el-icon-search" size="small" type="primary" @click="search"> </el-button>
<el-button v-if="canAdd" icon="el-icon-plus" size="small" type="primary" @click="add"> </el-button>
<el-button icon="el-icon-view" size="small" type="primary" @click="see"> </el-button>
<el-button v-if="canUpdate" icon="el-icon-edit" size="small" type="primary" @click="edit"> </el-button>
<el-button v-if="canDel" icon="el-icon-delete" size="small" type="danger" @click="del"> </el-button>
<el-button
v-if="canExport"
icon="el-icon-download"
size="small"
type="info"
@click="downloadExcel"
>
</el-button>
</el-row>
<el-row v-loading="config.loading" class="table-container">
<abstract-table :data="tableData" @row-click="rowClick" @expand-change="getSubList">
</template>
<template v-slot:tableColumn>
<el-table-column align="center" type="expand">
<template v-slot="{row}">
<liner-progress :show="row._loading"/>
@ -93,17 +74,12 @@
{{ getStatus(row.status) }}
</template>
</el-table-column>
</abstract-table>
<abstract-pagination :model="searchForm" @current-change="pageChange"/>
</el-row>
</el-card>
</template>
</list-page>
</template>
<script>
import docTableMixin from '@/mixin/docTableMixin'
import SearchForm from '@/component/form/Search'
import SearchFormItem from "@/component/form/Search/item"
import {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel} from "@/api/doc/sell/outbound"
export default {
@ -111,8 +87,6 @@ export default {
mixins: [docTableMixin],
components: {SearchForm, SearchFormItem},
data() {
this.api = {add, update, del, withdraw, pass, reject, getSubById, search, exportExcel}
return {

Loading…
Cancel
Save