vue功能组件
滚动条(scroll)
使用
<mini-scroll class="mini-scroll">
<div class="content">...滚动内容撑开</div>
</mini-scroll>
<style lang="less" scoped>
.mini-scroll {
width: 400px;
height: 440px;
border: 2px solid red;
background-color: pink;
.content {
font-size: 35px;
padding: 20px 40px 20px 20px;
}
}
</style>
组件
<template>
<div class="mini-scroll scroll-container d-flex">
<!-- 滚动视口容器 -->
<div class="scroll-body flex-1" @scroll="scroll" ref="scrollBody">
<!-- 滚动内容容器 -->
<div class="scroll-content">
<!-- 滚动内容插槽 -->
<slot></slot>
</div>
</div>
<!-- 滚动条容器 -->
<div class="scroll-slide-container">
<!-- 滚动条外层——包含了padding -->
<div class="scroll-slide-bar w-100 h-100 absCenterY">
<!-- 滚动条内层——滚动芯活动区域 -->
<div class="scroll-inner h-100 bd-filt" ref="slideInner">
<!-- 滚动芯 -->
<div
class="scroll-slider absCenterX w-100 bd-filt"
:style="{
height: `${sliderHeight}px`,
transform: `translateY(${sliderY}px) translateX(-50%)`
}"
ref="slider"
@touchstart="touchstart"
@touchmove="touchmove"
></div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
ratio: 1, // 映射比例
sliderHeight: 0, // 滚动芯高度
startY: 0, // 滚动芯起始触摸Y值
sliderY: 0 // 滚动芯滑动Y值
};
},
mounted() {
this.initScroll();
},
methods: {
// 初始化 计算比例与初始化slider高度
initScroll() {
// 容器相关高度
let clientHeight = this.$refs.scrollBody.clientHeight;
let scrollHeight = this.$refs.scrollBody.scrollHeight;
// 滚动条内部 滚动范围高度
let slideInnerHeight = this.$refs.slideInner.clientHeight;
// 计算比例与滚动芯高度
this.ratio = slideInnerHeight / scrollHeight;
this.sliderHeight = parseInt(this.ratio * clientHeight);
},
// 内容滚动 实时映射 slider位置
scroll: animationThrottle(function(e){
let $miniScroll = e.target;
let scrollTop = $miniScroll.scrollTop;
this.sliderY = scrollTop * this.ratio;
}),
// 触摸滚动条
touchstart: animationThrottle(function(e) {
let toucher = e.targetTouches[0];
this.startY = toucher.clientY;
}),
// 滑动滚动条
touchmove(e) {
let toucher = e.targetTouches[0];
let touchY = toucher.clientY;
let touchDistance = touchY - this.startY;
let scrollTop = this.$refs.scrollBody.scrollTop;
let nextTop = touchDistance / this.ratio + scrollTop;
this.$refs.scrollBody.scrollTop = nextTop;
this.startY = touchY;
}
}
};
</script>
<style lang="less" scoped>
.mini-scroll {
position: relative;
.scroll-body {
height: 100%;
scrollbar-width: none; /* firefox */
-ms-overflow-style: none; /* IE 10+ */
overflow-x: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
}
.scroll-slide-container {
position: absolute;
height: 100%; // 滚动条高度
width: 20px; // 滚动条宽度
top: 50%;
right: 0;
transform: translateY(-50%);
.scroll-slide-bar {
box-sizing: border-box;
padding: 39px 0; // 内层区域padding边距,控制可滑动区域和左右padding
background-size: 100% 100%;
.scroll-inner {
position: relative;
background-color: #eee;
.scroll-slider {
background-color: #fff;
}
}
}
}
}
</style>
数字滚动(scroll-num)
使用
<div class="amount-table d-flex jc-between">
<ScrollNum
v-for="(num, i) in amountArr[0]"
:key="'Int' + i"
ref="ScrollNum"
:number="num"
:delay="100 * (amountArr[0].length - 1 - i)"
class="scroll-num"
/>
<div class="scroll-num d-flex jc-center al-center">.</div>
<ScrollNum
v-for="(num, i) in amountArr[1]"
:key="'Float' + i"
ref="ScrollNum"
:number="num"
:delay="100 * (amountArr[1].length - 1 - i)"
class="scroll-num"
/>
</div>
amountArr() {
if (!this.moneyAmount)
return [
[0, 0, 0, 0, 0, 0],
[0, 0]
];
const strArr = this.moneyAmount.toFixed(2).split('.');
return strArr.map((numStr) => Array.from(numStr).map((str) => +str));
}
组件
<template>
<div class="scroll-wrap">
<ul
ref="scroller"
class="scroll-container"
:class="{ animating }"
:style="{ animationDuration: `${speed}ms`, animationDelay: `${delay}ms` }"
>
<li class="num">0</li>
<li class="num">1</li>
<li class="num">2</li>
<li class="num">3</li>
<li class="num">4</li>
<li class="num">5</li>
<li class="num">6</li>
<li class="num">7</li>
<li class="num">8</li>
<li class="num">9</li>
<li class="num">0</li>
</ul>
</div>
</template>
<script>
export default {
props: {
// 滚动至数字
number: { type: Number, default: 0, validator: (num) => Number.isInteger(num) && num < 10 && num >= 0 },
speed: { type: Number, default: 1000 }, // 速度
delay: { type: Number, default: 2000 }, // 延迟
duration: { type: Number, default: 1200 } // 持续时长
},
data() {
return {
animating: false
};
},
watch: {
animating(value) {
if (!value) return;
setTimeout(() => {
this.animating = false;
this.scrollToNumber();
}, this.duration + this.delay);
}
},
methods: {
scrollToNumber() {
this.$refs.scroller.style.transform = `translateY(-${this.number * 10}%)`;
}
}
};
</script>
<style lang="less" scoped>
.scroll-wrap {
width: 100%;
height: 100%;
overflow: hidden;
}
.scroll-container {
height: 1100%;
transform: translateY(0);
transition: filter 0.3s;
&.animating {
filter: blur(1px);
animation: scroll-vertical linear infinite;
}
}
.num {
display: flex;
height: 10%;
justify-content: center;
align-items: center;
}
@keyframes scroll-vertical {
form {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
</style>
折叠组件(collapse)
使用
<div
class="chat-detail"
v-collapse="{ max: 2, num: chat.messages.length }"
>
<div class="item"></div>
<div class="item"></div>
...
<div class="collapse-switch">展开/折叠</div>
</div>
命令参数
- max:子项数超出max则开启折叠
- num:子项数
- initHeight:初始折叠状态高度(友好处理滚动条/内容闪动)
指令
// 注册指令
directives: {
collapse: {
bind: v_collapse,
componentUpdated: v_collapse,
unbind(el){
// 解绑命令时解绑折叠事件
el.removeEventListener("click", collapseEvent);
}
}
}
// 折叠指令
function v_collapse(el, binding){
var options = binding.value || {};
const max = options.max || 0; // 显示条数
const num = options.num || 0; // 当前总条数
const initHeight = options.initHeight || "auto"; // 初始折叠高度
if(num > max){
// 默认折叠状态
el.style.overflow = "hidden";
el.dataset.collapsed = "collapsed";
el.style.height = initHeight;
var loading;
var imgList = [].slice.call(el.querySelectorAll("img"));
// 子项有img时,全部加载后才计算折叠高度
if(imgList.length > 0){
loading = Promise.all(
imgList.map($img => new Promise(resolve => {
$img.addEventListener("load", () => resolve());
$img.addEventListener("error", () => resolve());
}))
)
}else{
loading = Promise.resolve()
}
loading.then(() => {
// 计算折叠高度
var collapsedHeight = []
.slice.call(el.children, 0, max)
.reduce((total, $ele) => total + $ele.clientHeight, 0);
el.style.height = collapsedHeight + "px";
el.dataset.collapsedHeight = collapsedHeight;
el.addEventListener("click", collapseEvent);
});
}else{
el.style.height = "auto";
el.style.overflow = "unset";
}
}
// 折叠事件
function collapseEvent(e){
var el = e.currentTarget;
var target = e.target;
var list = [].slice.call(el.querySelectorAll(".collapse-switch"));
var delegated = list.some($switch => $switch.contains(target));
if(!delegated) return;
// 切换折叠状态
var collapsed = el.dataset.collapsed;
var collapsedHeight = el.dataset.collapsedHeight + 'px' || 'auto';
el.style.height = collapsed ? el.scrollHeight + 'px' : collapsedHeight;
el.style.overflow = collapsed ? "unset" : "hidden";
el.dataset.collapsed = collapsed ? "" : "collapsed";
}
更好的实践: 对满足折叠的内容区分折叠状态,折叠状态下截取内容item长度,展开交互时切换内容折叠状态,内容item全部绑定;最后添加vue transition动画控制即可;
预加载(loading-bar)
页面预加载静态资源,并用loading加载条实时显示加载进度;也可以做上传下载loading交互;
// Node
var fs = require("fs");
var path = require("path"),
filesList = {}, // 资源json
imgPath = "../../assets/img", // 资源相对路径
imgPathName = "ROOT"; // 当前读取层级,起始为ROOT
function readFileList(dir, dirName, filesList = {}) {
const files = fs.readdirSync(dir);
files.forEach(item => {
const stat = fs.statSync(path.join(dir, item));
if (stat.isDirectory()) {
readFileList(`${dir}/${item}`, item, filesList);
} else {
if (!filesList[dirName]) {
filesList[dirName] = [];
}
var fullPath = `${dir}/${item}`.replace(`${path.resolve(__dirname, imgPath)}/`, "");
filesList[dirName].push(fullPath);
}
});
return filesList;
}
readFileList(path.resolve(__dirname, imgPath), imgPathName, filesList);
let str = `
const imgList=${JSON.stringify(filesList)}
export default imgList
`;
fs.writeFileSync(path.resolve(__dirname, "../../assets/js/imgList.js"), str);
季度选择器(el-quarter)
组件功能
支持季度选择的表单组件,季度选择本质就是日期选择(3/6/9/12月最后一天);
与element-ui时间/日期选择器都支持 format
valueFormat
,定义输入输出值格式;支持disabledDate
,控制不可选择季度;
disabled
属性控制组件是否被禁用;
数据流规则
数据字段:
value 外部传入值,组件功能通过修改value实现
date 初始值由value计算得到,组件内因交互而需维护的状态
value => parseValue(value) => 初始date => 组件状态
组件交互 => 操作date => 组件状态 => 修改value => 同步date => 组件状态
文件结构
QuarterPicker (参考element-ui)
- src
- QuarterPicker.vue
- index.js
code
// element.js
import QuarterPicker from '@/components/QuarterPicker';
...
Vue.use(QuarterPicker);
// index.js
import QuarterPicker from './src/QuarterPicker.vue';
export default {
install(Vue) {
Vue.component('ElQuarterPicker', QuarterPicker);
}
};
// QuarterPicker.vue
<template>
<el-popover popper-class="quarter-picker-popover" :disabled="disabled">
<el-input ref="reference" slot="reference" :value="InputText" :disabled="disabled" readonly>
<i slot="prefix" class="el-icon-date"></i>
</el-input>
<div class="el-quarter-panel">
<div class="el-quarter-header">
<button
type="button"
aria-label="前一年"
class="el-picker-panel__icon-btn el-icon-d-arrow-left"
@click="handlePrevYear"
></button
><span role="button" class="el-date-picker__header-label">年</span>
<button
type="button"
aria-label="后一年"
class="el-picker-panel__icon-btn el-icon-d-arrow-right"
@click="handleNextYear"
></button>
</div>
<div class="el-quarter-body">
<div v-for="i in 4" :key="i">
<a class="cell" :class="getCellClass(i)" @click="onSelectQuarter(i)">Q</a>
</div>
</div>
</div>
</el-popover>
</template>
<script>
import { formatDate, parseDate, prevYear, nextYear } from 'element-ui/src/utils/date-util';
export default {
props: {
value: { type: [Date, Number, String], default: () => new Date() },
format: { type: String, default: 'yyyy-MM-dd' }, // 回显日期格式
valueFormat: { type: String, default: '' }, // 输入输出日期格式
disabledDate: { type: Function, default: () => {} },
disabled: { type: Boolean, default: false }
},
data() {
return {
date: parseValue(this.value, this.valueFormat)
};
},
computed: {
year() {
return this.date?.getFullYear();
},
InputText() {
const date = parseValue(this.value, this.valueFormat);
return formatValue(date, this.format);
}
},
watch: {
value(date) {
this.date = parseValue(date, this.valueFormat);
}
},
methods: {
prevYear,
nextYear,
formatValue,
getCellClass(i) {
const className = [];
const quarterDate = new Date(this.year, i * 3, 0);
const currentQuarterDate = parseValue(this.value, this.valueFormat);
if (formatValue(currentQuarterDate, this.valueFormat) === formatValue(quarterDate, this.valueFormat))
className.push('current');
if (this.disabledDate(quarterDate)) className.push('disabled');
return className.join(' ');
},
handlePrevYear() {
this.date = prevYear(this.date);
},
handleNextYear() {
this.date = nextYear(this.date);
},
onSelectQuarter(quarter) {
const quarterDate = new Date(this.year, quarter * 3, 0);
if (this.disabledDate(quarterDate)) return;
this.$emit('input', formatValue(quarterDate, this.valueFormat));
}
}
};
// 解析传入的值为Date对象
function parseValue(value, format) {
if (value instanceof Date) return value;
if (format === 'timestamp') return new Date(value);
return parseDate(value, format);
}
// 格式化传入的Date对象
function formatValue(date, format) {
if (!date) return '';
if (!format) return date;
if (format === 'timestamp') return date.getTime();
if (format === 'yyyy-QN') {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const quarter = Math.ceil(month / 3);
return `${year}-Q${quarter}`;
}
return formatDate(date, format);
}
</script>
<style>
.quarter-picker-popover {
padding: 12px 12px 0;
}
</style>
<style lang="scss" scoped>
.el-quarter-panel {
width: 292px;
.el-quarter-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #ebeef5;
padding-bottom: 12px;
line-height: 30px;
.el-picker-panel__icon-btn {
margin-top: 0;
padding: 0 6px;
}
}
.el-quarter-body {
display: flex;
font-size: 12px;
div {
flex: 1;
padding: 8px 0;
text-align: center;
cursor: pointer;
.cell {
display: block;
margin: 0 auto;
width: 60px;
height: 36px;
line-height: 36px;
color: #606266;
&:hover,
&.current {
color: #409eff;
transition: 0.3s all;
}
&.current {
font-weight: bold;
}
&.disabled {
color: #c0c4cc;
background-color: #f5f7fa;
cursor: not-allowed;
}
}
}
}
}
::v-deep {
.el-input__prefix {
left: 8px;
}
.el-input__suffix {
right: 8px;
}
.el-icon-circle-close {
cursor: pointer;
}
}
</style>
// 使用
<el-quarter-picker
v-model="config.quarterDate"
format="yyyy-QN"
value-format="yyyy-MM"
:disabled-date="disabledDate"
@input="updateFundInfo"
/>
相关概念
时间处理:parseValue转为便于计算的值,formatValue转为用于业务回显的值;
水平折叠面板动画
element-ui 的 Collapse折叠面板仅支持垂直折叠动画,添加水平折叠动画封装
// element.js
import DrawerTransition from '@/components/DrawerTransition';
......
Vue.use(DrawerTransition);
// index.js
import DrawerTransition from './src/DrawerTransition.vue';
export default {
install(Vue) {
Vue.component('ElDrawerTransition', DrawerTransition);
}
};
// DrawerTransition.vue
<template>
<el-drawer-transition>
<slot />
</el-drawer-transition>
</template>
<script>
import { addClass, removeClass } from 'element-ui/lib/utils/dom';
export default {
name: 'EkDrawerTransition',
components: {
'el-drawer-transition': {
functional: true,
render(createElement, context) {
const data = {
on: {
beforeEnter(el) {
addClass(el, 'drawer-transition');
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingLeft = el.style.paddingLeft;
el.dataset.oldPaddingRight = el.style.paddingRight;
el.style.width = '0';
el.style.paddingLeft = 0;
el.style.paddingRight = 0;
},
enter(el) {
el.dataset.oldOverflow = el.style.overflow;
if (el.scrollWidth !== 0) {
el.style.width = el.scrollWidth + 'px';
el.style.paddingLeft = el.dataset.oldPaddingLeft;
el.style.paddingRight = el.dataset.oldPaddingRight;
} else {
el.style.width = '';
el.style.paddingLeft = el.dataset.oldPaddingLeft;
el.style.paddingRight = el.dataset.oldPaddingRight;
}
el.style.overflow = 'hidden';
},
afterEnter(el) {
removeClass(el, 'drawer-transition');
el.style.width = '';
el.style.overflow = el.dataset.oldOverflow;
},
beforeLeave(el) {
if (!el.dataset) el.dataset = {};
el.dataset.oldPaddingLeft = el.style.paddingLeft;
el.dataset.oldPaddingRight = el.style.paddingRight;
el.dataset.oldOverflow = el.style.overflow;
el.style.width = el.scrollWidth + 'px';
el.style.overflow = 'hidden';
},
leave(el) {
if (el.scrollWidth !== 0) {
addClass(el, 'drawer-transition');
el.style.width = 0;
el.style.paddingLeft = 0;
el.style.paddingRight = 0;
}
},
afterLeave(el) {
removeClass(el, 'drawer-transition');
el.style.width = '';
el.style.overflow = el.dataset.oldOverflow;
el.style.paddingLeft = el.dataset.oldPaddingLeft;
el.style.paddingRight = el.dataset.oldPaddingRight;
}
}
};
return createElement('transition', data, context.children);
}
}
}
};
</script>
<style lang="scss" scoped>
.drawer-transition {
transition: 0.3s width ease-in-out, 0.3s padding-left ease-in-out, 0.3s padding-right ease-in-out;
}
</style>
// 使用
<el-drawer-transition>
<div v-show="collapse">...content</div>
</el-drawer-transition>
文本一键编辑
<div v-if="editNameId === item.id">
<el-input
v-model.trim="item.name"
class="name-input"
maxlength="10"
@blur.stop="editedName(item.name)"
>
<template #suffix><i class="icon icon-editing"></i></template>
</el-input>
</div>
<div v-else class="title d-flex align-center">
<i class="icon icon-edit" @click.stop="editName(item.id)"></i>
</div>
// 编辑名称时
editName(id) {
this.editNameId = id;
this.$nextTick(() => {
document.querySelector('.name-input input').focus();
});
}
// 编辑名称完成时
editedPageName(newName) {
if (!isvalid) {
console.log(`${newName}校验未通过`);
document.querySelector('.name-input input').focus();
} else {
this.editNameId = '';
}
}
验证码输入
<el-form-item class="form-item" :inline="true" prop="code">
<div class="input-code">
<el-input v-model.trim="form.code" type="text" placeholder="请输入验证码" maxlength="6"></el-input>
<el-button
class="toggle"
:class="{ btnActive: isValidTel }"
:disabled="!form.mobile || !!waitingTime"
@click="getVerifyCode"
></el-button>
</div>
</el-form-item>
waitingTime
:下次可获取等待时间
isValidTel
:是否有效手机号
getVerifyCode
:校验手机号并获取验证码
validBtnText
:waitingTime ? "重新获取" + waitingTime.toString().padStart(2, '0')} + "s" : '获取验证码'
自定义下拉菜单选择
<el-dropdown
class="tagList-dropdown fulled-w"
trigger="click"
placement="bottom-start"
@visible-change="onVisibleChange"
>
<div class="dropdown-toggle d-flex jc-between al-center bgc-white">
<div class="d-flex">
<div v-for="(item, i) in tagList" :key="i" class="tag-item d-flex al-center px-8 t-theme fs-xs bd-filt">
<div class="flex-1 t-ellipsis"></div>
<i class="el-icon-error ml-8" @click.stop="onSelectTag(item)"></i>
</div>
</div>
<i class="el-icon-arrow-down" :class="dropdownIn ? 'dropdown-in' : 't-light'"></i>
</div>
<el-dropdown-menu slot="dropdown" class="tagList-dropdown-menu">
<div v-for="(item, index) in tagData" :key="index" :class="{ floor: index < tagData.length - 1 }">
<div class="title py-16"></div>
<div class="list d-flex flex-wrap t-light">
<div
v-for="(tag, i) in item.list"
:key="i"
class="item mr-16 mb-16 pointer"
:class="{ 't-theme': isSelectedTag(tag) }"
@click="onSelectTag(tag)"
>
</div>
</div>
</div>
</el-dropdown-menu>
</el-dropdown>
dropdownIn: false 是否处于下拉状态
tagDate: [] 选项数据
tagList: [] 已选则数据
methods: {
// 是否为已选择标签
isSelectedTag(tag) {
return this.tagList.includes(tag);
},
// 选择标签
onSelectTag(tag) {
const index = this.tagList.indexOf(tag);
// 添加选中
if (index === -1) {
this.tagList.push(tag);
}
// 取消选中
else {
this.tagList.splice(index, 1);
}
}
// 标签选择下拉菜单 显示/隐藏
onVisibleChange(visible) {
this.dropdownIn = visible;
}
}
<style lang="scss" scoped>
.tagList-dropdown {
.dropdown-toggle {
border: 1px solid #cecece;
border-radius: 4px;
padding: 16px 7px 16px 16px;
min-height: 62px;
cursor: pointer;
.tag-item {
border: 1px solid #ccc;
max-width: 94px;
height: 28px;
line-height: 26px;
background-color: #f4f9ff;
&:not(:last-of-type) {
margin-right: 12px;
}
}
.el-icon-arrow-down {
border-radius: 50%;
padding: 2px;
transition: all 0.3s;
&.dropdown-in {
transform: rotate(180deg);
}
}
}
}
.tagList-dropdown-menu {
margin: 5px 0;
padding: 0 24px 8px;
width: 360px;
.floor:not(:last-of-type) {
border-bottom: 1px solid #c0c0c0;
}
::v-deep {
.popper__arrow {
display: none;
}
}
}
</style>
瀑布流布局
安装
npm i vue-masonry -S
引入
import { VueMasonryPlugin } from 'vue-masonry';
app.use(VueMasonryPlugin);
app.mount('#app');
使用
// 瀑布流布局自适应
const masonryId = generateID();
const redrawVueMasonry = inject('redrawVueMasonry');
// 数据改变重新排版
watch(list, () => {
nextTick(() => redrawVueMasonry(masonryId));
});
// 子项状态时重新排版redrawVueMasonry...
<div
v-masonry="masonryId"
item-selector=".card"
:gutter="36"
transition-duration="0"
>
<ArticleCard
v-for="item in list"
class="card"
:key="item.id"
/>
</div>
gutter: 子项间距值
transition-duration: 子项动画效果时长