Open zptime opened 3 years ago
<template> <!-- 基础列表穿梭框 SingleTransfer --> <div class="m-common-transfer m-single-common-transfer"> <!-- 源数据展示 --> <div class="mct-transfer-list"> <!-- 头部 --> <div class="mct-transfer-list-header"> <template v-if="showSelectAll"> <a-checkbox :indeterminate="sourceIsIndeterminate" v-model="sourceCheckedAll" @change="handleSourceSelectedAll" class="mct-transfer-list-header-checkbox" /> <div class="mct-transfer-list-header-selected"> <span v-if="sourceCheckedKeys.length" >{{ sourceCheckedKeys.length }}/{{ sourceAllKeys.length }} {{ locale.itemUnit }}</span > <span v-else>{{ sourceAllKeys.length }} {{ locale.itemUnit }}</span> </div> </template> <div class="mct-transfer-list-header-title">{{ sourceTitle }}</div> </div> <!-- 主体内容 --> <div class="mct-transfer-list-body"> <!-- 搜索框 --> <div class="mct-transfer-list-search"> <a-input v-model="sourceKeyword" :placeholder="searchPlaceholder" class="mct-transfer-list-search-input" @pressEnter="handleSourceSearch" /> <a class="mct-transfer-list-search-action"> <a-icon type="close-circle" theme="filled" v-if="sourceKeyword && sourceKeyword.length > 0" @click="sourceKeyword = ''" /> <a-icon type="search" v-else /> </a> </div> <!-- 源列表 --> <div class="mct-transfer-list-source"> <a-list :data-source="sourceData" :locale="{ emptyText: locale.sourceEmptyText }" class="mct-list" > <RecycleScroller class="mct-list-recycle" :items="sourceData" :item-size="36" key-field="key" > <a-list-item slot-scope="{ item, index }" :key="`source-${index}`" class="mct-list-item" > <a-checkbox :checked="item.checked" :disabled="item.disabled" class="mct-list-item-checkbox" @change="(e) => handleSourceSelect(e, index, item.key)" ></a-checkbox> <div :class="['mct-list-item-title', { disabled: item.disabled }]" > <template v-if="sourceKeyword && item.title.includes(sourceKeyword)" > <div class="title title-other"> <span>{{ item.title.substr(0, item.title.indexOf(sourceKeyword)) }}</span> <span class="active">{{ sourceKeyword }}</span> <span>{{ item.title.substr( item.title.indexOf(sourceKeyword) + sourceKeyword.length ) }}</span> <span v-if="showDesc" class="desc">{{ item.desc }}</span> </div> </template> <template v-else> <div class="title"> {{ item.title }} <span v-if="showDesc" class="desc">{{ item.desc }}</span> </div> </template> </div> </a-list-item> </RecycleScroller> </a-list> </div> </div> </div> <!-- 目标数据展示 --> <div class="mct-transfer-list"> <!-- 头部 --> <div class="mct-transfer-list-header"> <template v-if="showSelectAll"> <div class="mct-transfer-list-header-selected"> <span v-if="targetCheckedKeys.length" >{{ targetCheckedKeys.length }}/{{ targetAllKeys.length }} {{ locale.itemUnit }}</span > <span v-else>{{ targetAllKeys.length }} {{ locale.itemUnit }}</span> </div> </template> <span class="mct-transfer-list-header-title">{{ targetTitle }}</span> </div> <!-- 主体内容 --> <div class="mct-transfer-list-body"> <!-- 搜索框 --> <div class="mct-transfer-list-search"> <a-input v-model="targetKeyword" :placeholder="searchPlaceholder" class="mct-transfer-list-search-input" @pressEnter="handleTargetSearch" /> <a class="mct-transfer-list-search-action"> <a-icon type="close-circle" theme="filled" v-if="targetKeyword && targetKeyword.length > 0" @click="targetKeyword = ''" /> <a-icon type="search" v-else /> </a> </div> <!-- 目标列表 --> <div class="mct-transfer-list-source"> <a-list :data-source="targetData" :locale="{ emptyText: locale.targetEmptyText }" class="mct-list" > <RecycleScroller class="mct-list-recycle" :items="targetData" :item-size="36" key-field="key" > <a-list-item slot-scope="{ item, index }" :key="`target-${index}`" class="mct-list-item" > <div class="mct-list-item-title"> <template v-if="targetKeyword && item.title.includes(targetKeyword)" > <div class="title title-other"> <span>{{ item.title.substr(0, item.title.indexOf(targetKeyword)) }}</span> <span class="active">{{ targetKeyword }}</span> <span>{{ item.title.substr( item.title.indexOf(targetKeyword) + targetKeyword.length ) }}</span> <span v-if="showDesc" class="desc">{{ item.desc }}</span> </div> </template> <template v-else> <div class="title"> {{ item.title }} <span v-if="showDesc" class="desc">{{ item.desc }}</span> </div> </template> </div> <div class="mct-list-item-del" @click="handleTargetChange(index, item.key)" > <img src="https://github.com/zptime/resources/blob/master/images/transfer/ic_close.svg" /> </div> </a-list-item> </RecycleScroller> </a-list> </div> </div> </div> </div> </template> <script> import * as R from "ramda"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; // // 数据源数据示例 // const dataSource = [ // { key: 1, title: "选项1", desc: "技术部" }, // { key: 1, title: "选项1", desc: "技术部" }, // ]; // const targetkeys = [1]; export default { name: "MeSingleTransfer", props: { dataSource: { // 数据源 type: Array, default: () => [], }, targetKeys: { // 右侧框数据的 key 集合 type: Array, default: () => [], }, disabledKeys: { // 禁用 key 集合 type: Array, default: () => [], }, // 标题 titles: { type: Array, default: () => ["可选", "已选"], }, // 语言配置 locale: { type: Object, default: () => { return { itemUnit: "项", sourceEmptyText: "暂无可选数据", targetEmptyText: "暂无已选数据", }; }, }, searchPlaceholder: { type: String, default: "请输入搜索关键字", }, // 是否展示全选勾选框 showSelectAll: { type: Boolean, default: false, }, // 是否展示描述信息 showDesc: { type: Boolean, default: false, }, }, data() { return { sourceTargetKeys: [], // 显示在右侧框数据的 key 集合 // 源列表 // sourceData: [], // 数据源 sourceAllKeys: [], // 全部key sourceCheckedKeys: [], // 选中key sourceCheckedAll: false, // 是否全选 sourceIsIndeterminate: false, // 是否半选 sourceKeyword: "", // 筛选关键字 // 目标列表 // targetData: [], targetAllKeys: [], // 全部key targetCheckedKeys: [], // 选中key targetKeyword: "", // 筛选关键字 }; }, components: { RecycleScroller, }, computed: { // 源数据列表 sourceData() { let filterKeys = this.disabledKeys && this.disabledKeys.length ? R.concat(this.disabledKeys, this.sourceTargetKeys) : this.sourceTargetKeys; let result = R.map( (o) => { if (R.includes(o.key, filterKeys)) { o.disabled = true; o.checked = true; o.title = `${o.title}(已配置)`; } else { o.disabled = false; o.checked = false; } return o; }, this.sourceKeyword ? R.filter( (r) => R.includes(this.sourceKeyword, r.title), R.clone(this.dataSource) ) : R.clone(this.dataSource) ); this.sourceAllKeys = this.getAllKeys(result); this.sourceCheckedKeys = R.filter( (o) => R.includes(o, this.sourceAllKeys), this.sourceTargetKeys ); return result; }, // 源数据列表 targetData() { let result = R.filter( (o) => R.includes(o.key, this.sourceTargetKeys), this.targetKeyword ? R.filter( (r) => R.includes(this.targetKeyword, r.title), R.clone(this.dataSource) ) : R.clone(this.dataSource) ); this.targetAllKeys = this.getAllKeys(result); return result; }, // 源数据菜单名 sourceTitle() { let [text] = this.titles; return text; }, // 目标数据菜单名 targetTitle() { let [, text] = this.titles; return text; }, }, watch: { // 选中到右侧的数据监听 targetKeys: { handler: function(val) { this.sourceTargetKeys = val || []; }, deep: true, immediate: true, }, // 左侧 状态监测 sourceCheckedKeys(val) { if (val && val.length > 0) { // 总半选是否开启 this.sourceIsIndeterminate = true; // 总全选是否开启 - 根据选中节点的数量是否和源数据长度相等 let allKeys = this.getAllKeys(this.sourceData); let allCheck = R.filter((o) => R.includes(o.key, allKeys), val); if (R.length(allKeys) === R.length(allCheck)) { // 关闭半选 开启全选 this.sourceIsIndeterminate = false; this.sourceCheckedAll = true; } else { this.sourceIsIndeterminate = true; this.sourceCheckedAll = false; } } else { this.sourceIsIndeterminate = false; this.sourceCheckedAll = false; } }, }, methods: { // 源数据select事件 handleSourceSelect(e, index, key) { let checked = e.target.checked; if (checked) { this.sourceTargetKeys = R.append(key, this.sourceTargetKeys); } else { this.sourceTargetKeys = R.filter( (o) => o !== key, this.sourceTargetKeys ); } this.$emit("on-change", this.sourceTargetKeys, this.targetData); }, // 目标数据change事件:删除 handleTargetChange(index, key) { this.sourceTargetKeys = R.filter((o) => o !== key, this.sourceTargetKeys); this.$emit("on-change", this.sourceTargetKeys, this.targetData); }, // 源数据onSearch事件 handleSourceSearch(e) {}, // 目标数据onSearch事件 handleTargetSearch(e) {}, // 源数据全选事件 handleSourceSelectedAll(e) { if (this.sourceData.length === 0) { return; } if (e.target.checked) { this.sourceTargetKeys = R.uniq( R.concat(this.sourceTargetKeys, this.getAllKeys(this.sourceData)) ); } else { this.sourceTargetKeys = R.difference( this.sourceTargetKeys, this.getAllKeys(this.sourceData) ); } }, // 获取数据源key getAllKeys(source) { if (!(source && source.length)) { return []; } return R.map((o) => o.key, source); }, }, }; </script> <style lang="scss"> $px: 1px; .m-single-common-transfer { box-sizing: border-box; margin: 0; padding: 0; color: #333; font-size: 14px; line-height: 1; list-style: none; position: relative; display: flex; width: 526px; justify-content: space-between; .mct-transfer-list { vertical-align: middle; border: 1px solid #ebebeb; border-radius: 2px; width: 254px; height: 400px; overflow: hidden; // 头部标题 &-header { width: 100%; height: 40 * $px; padding: 0 12px; color: #333333; background: #f7f9fa; border-radius: 2px 2px 0 0; display: flex; align-items: center; &-checkbox { margin-right: 8px; } &-selected { flex: 1; display: flex; align-items: center; } &-title { color: #808080; } } // 内容区 &-body { position: relative; height: 100%; .ant-list-empty-text { margin-top: 100px; color: #c3c3c3; } } // 搜索框 &-search { width: 100%; position: relative; padding-bottom: 0; padding: 12 * $px 12 * $px 0; &-input { padding: 0 28px 0 12px; } &-action { position: absolute; top: 12px; right: 12px; bottom: 12px; width: 40px; color: rgba(0, 0, 0, 0.25); line-height: 32px; text-align: center; } } // 数据列表 &-source { margin: 12px 0; padding-left: 12px; height: 300px; overflow-y: auto; overflow-x: hidden; } // 目标数据列表 .mct-list { &-recycle { height: 300px; padding-right: 12px; } &-item { position: relative; display: flex; align-items: center; padding: 8px 0; border-bottom: 0; &-checkbox { margin-right: 8px; } &-title { display: flex; flex: 1; min-width: 0; overflow: hidden; .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; &-other { font-size: 0; > span { font-size: 14px; } } .desc { font-size: 12px; color: #c3c3c3; margin-left: 5px; } } &.disabled, .disabled { color: #c3c3c3; opacity: 1; } .active { color: #ffad33; } } &-del { cursor: pointer; width: 12px; text-align: right; height: 20px; > img { width: 6px; height: 6px; position: relative; top: -2px; } } } } } input[disabled="disabled"] { opacity: 0 !important; } } </style>