vue 案例
vue 案例
1. 案例1:H5 下拉加载更多(模拟微信聊天记录)
参考:https://blog.csdn.net/sensation_cyq/article/details/112661714
- 原版写法
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>H5 下拉加载更多(模拟微信聊天记录)</title>
<style>
.container {
width: 300px;
height: 300px;
overflow: auto;
border: 1px solid;
margin: 10px auto;
}
.item {
height: 29px;
line-height: 30px;
text-align: center;
border-bottom: 1px solid #aaa;
}
</style>
</head>
<body>
<div id="app">
<div class="container" ref="container">
<div class="item">{{loadText+"第"+pageNum+"页"}}</div>
<div v-for="(item, index) in list" :key="index" class="item">{{item}}</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
new Vue({
el: '#app',
data: {
scrollHeight: 0,
list: [],
loadText:"加载中...",
pageSize:20,
pageNum:1,
},
mounted() {
this.initData();
const container = this.$refs.container;
//这里的定时是为了列表首次渲染后获取scrollHeight并滑动到底部。
setTimeout(() => {
this.scrollHeight = container.scrollHeight;
container.scrollTo(0, this.scrollHeight);
}, 10);
container.addEventListener('scroll', (e) => {
//这里的2秒钟定时是为了避免滑动频繁,节流
setTimeout(() => {
if(this.list.length>=90){
this.loadText = "加载完成";
return;
}
//滑到顶部时触发下次数据加载
if (e.target.scrollTop == 0) {
//将scrollTop置为10以便下次滑到顶部
e.target.scrollTop = 10;
//加载数据
this.initData();
//这里的定时是为了在列表渲染之后才使用scrollTo。
setTimeout(() => {
e.target.scrollTo(0, this.scrollHeight - 30);//-30是为了露出最新加载的一行数据
}, 100);
}
}, 2000);
});
},
methods:{
//初始数据
initData() {
for (var i = 20; i > 0; i--) {
this.list.unshift(i)
}
this.pageNum++;
}
}
})
</script>
</body>
</html>
- 改进版
<template>
<!--缺陷通知页面-->
<div class="page">
<!--页面头部-->
<PageHeader :type="pageType" :title="title" position="center">
<!--<template #header-right>
<van-badge :content="taskNum">
<div class="page-right">已读</div>
</van-badge>
</template>-->
</PageHeader>
<!--页面主体-->
<div class="page-body" :class="pageType === 'tab' ? 'page-tab-body' : ''">
<div style="overflow-y: scroll; height: 100%;" ref="container">
<MyLoading :loading="loading" :finished="finished"></MyLoading>
<!--缺陷通知每一项-->
<InformListItem type="hidden" v-for="(item, index) in list" :ref="'informlistitem'+index"
:detail="item"></InformListItem>
</div>
</div>
</div>
</template>
<script>
import PageHeader from '@/components/page-header/PageHeader';//
import MyLoading from '@/components/MyLoading.vue';// loading 组件-自定义
import InformListItem from '../components/InformListItem.vue';// 内容主体
import { Toast } from 'vant';
import { commonRequestJSON } from '../../../net/commonRequest'
export default {
name: 'HiddenInform',
components: { PageHeader, InformListItem, MyLoading },
data () {
return {
title: '',
loading: false,
finished: false,
refreshing: false,
queryParams: {
pageNum: 0,
pageSize: 10,
receiveId: this.$store.state.userInfo.userId
},
list: [],
loadText: '',
dataSumNum: 0,// 列表总数
scrollToBottomFlag: false,// 已经定位到底部
informlistitem_height: 0,// 单个子组件高度
scrollHeight: 0,
}
},
// 初始化页面完成后
mounted () {
var this_ = this
function fistCallback () {
this_.scrollToBottom()
//this_.getListItemHeight()
}
this.onLoad(fistCallback)
const container = this.$refs.container
container.addEventListener('scroll', (e) => {
//这里的2秒钟定时是为了避免滑动频繁,节流
setTimeout(() => {
if (this.finished) {
return
}
//滑到顶部时触发下次数据加载
if (e.target.scrollTop == 0) {
//将scrollTop置为10以便下次滑到顶部
e.target.scrollTop = 10// 防止一直在定部,导致一直在加载下一页
//加载数据
var this_ = this
function callback () {
/**
* 如果想加载下一页时,显示出 最新加载的一行,则 使用 this_.scrollHeight - this_.informlistitem_height 注:初始时需要调用 getListItemHeight 获取单行高度
setTimeout(() => {
e.target.scrollTo(0, this_.scrollHeight );//-30是为了露出最新加载的一行数据
}, 100);
*/
//这里的定时是为了在列表渲染之后才使用scrollTo。
this_.$nextTick(() => {
e.target.scrollTo(0, this_.scrollHeight );//-30是为了露出最新加载的一行数据
})
}
this.onLoad(callback)
}
}, 1000)
})
},
created () {
this.title = this.$route.query.pname
this.queryParams.typeId = this.$route.query.pid
this.loading = true
},
methods: {
/*数据加载,*/
onLoad (callback) {
this.loading = true;
this.queryParams.pageNum++
var this_ = this
function dataCallback (res) {
this_.loading = false
// 加载状态结束
if (res.code == 200) {
if (res.rows == undefined || res.rows.length == 0) {
this_.finished = true// 数据加载完成
this_.loading = false
return
}
res.rows.forEach((item, index) => {
if (!item.extraInfo) {
item.extraInfo = {}
} else {
item.extraInfo = JSON.parse(item.extraInfo)
}
this_.list.unshift(item)
})
if (this_.list.length == res.total) {
this_.finished = true// 数据加载完成
}
if (typeof callback === 'function') {
callback(true)
}
} else {
Toast.fail('数据加载异常!')
this_.finished = true
}
}
function dataErrCallback (data) {
// 异常 没有数据
Toast.fail('数据加载异常!')
this_.loading = false
this_.finished = true
}
commonRequestJSON('get', 'system/pushContent/findSefMessage', this.queryParams, dataCallback, dataErrCallback)
},
// 获取子组件的高度
getListItemHeight () {
var this_ = this
this.$nextTick(() => {
this_.informlistitem_height = this_.$refs['informlistitem0'][0].$refs.inform.offsetHeight
})
},
onRefresh () {
// 清空列表数据
this.list = []
this.finished = false
// 重新加载数据
// 将 loading 设置为 true,表示处于加载状态
this.queryParams.pageNum = 0
this.refreshing = false
this.onLoad()
},
// 初始加载,定位到最低部
scrollToBottom () {
var this_ = this
this.$nextTick(() => {
// var div = this_.$refs.container// document.getElementById('data-list-content');div.scrollTop = div.scrollHeight
const container = this_.$refs.container
//这里的定时是为了列表首次渲染后获取scrollHeight并滑动到底部。
this_.scrollHeight = container.scrollHeight
container.scrollTo(0, this.scrollHeight)
})
}
},
}
</script>
<style lang="less" scoped>
.page {
&-body {
overflow: auto;
background: #F3F6FA;
padding-bottom: 20px;
}
&-right {
font-size: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.inform[data-v-000b813e] {
background: #F3F6FA;
}
}
</style>
子组件:MyLoading
<template>
<div>
<van-loading size="24px" vertical v-if="loading">加载中...</van-loading>
<div class="van-loading--vertical" style="font-size: 24px" v-if="finished">
<span class="van-loading__text">加载完成</span>
</div>
</div>
</template>
<script>
/*自定义 loading 组件,结合 vant 的 list 组件,*/
export default {
name: 'MyLoading',
components: {},
props: {
loading: {// 加载中
type: Boolean,
default: false,
},
finished: {// 结束
type: Boolean,
default: false,
},
},
}
</script>
子组件 :InformListItem
<template>
<!-- 缺陷/隐患通知 -->
<div class="inform" ref="inform">
xxxx
</div>
</template>
2. 类似外卖点餐界面的左右侧菜单联动:点击左侧使右侧滚动到对应位置,右侧滚动时选中左侧对应选项
demo\外卖点餐界面的左右侧菜单联动.html
参考:https://blog.csdn.net/liangziqi233/article/details/120352529
.foods-wrapper { flex: 1;} 右侧区域,通过设置flex: 1 来平分 身下的空间(左侧宽度固定,通过设置flex:1 平分剩余空间),这样处理是 宽度合再一起100%,
如果需求是,左侧菜单能够隐藏,右侧通过平移的方法展示,同时右侧的区域宽度始终是100%,这时可以设置
.foods-wrapper { width: 100%; flex-shrink:0} ,其中 flex-shrink 表示伸缩不变形
2.1效果图

2.2添加依赖:
npm install better-scroll
npm install stylus stylus-loader@3.0.1 --save-dev
2.3vue源码
<template>
<div>
<div class="goods">
<div class="menu-wrapper">
<ul>
<li
class="menu-item"
v-for="(good, index) in goods"
:key="index"
:class="{ current: index === currentIndex }"
@click="clickMenuItem(index)"
>
<span class="text">
<img class="icon" :src="good.icon" v-if="good.icon" />
{{ good.name }}
</span>
</li>
</ul>
</div>
<div class="foods-wrapper">
<ul ref="foodsUl">
<li
class="food-list-hook"
v-for="(good, index) in goods"
:key="index"
>
<h1 class="title">{{ good.name }}</h1>
<ul>
<li
class="food-item"
v-for="(food, index) in good.foods"
:key="index"
>
<div class="icon">
<img width="57" height="57" :src="food.icon" />
</div>
<div class="content">
<h2 class="name">{{ food.name }}</h2>
<p class="desc">{{ food.description }}</p>
<div class="extra">
<span class="count">月售{{ food.sellCount }}份</span>
<span>好评率{{ food.rating }}%</span>
</div>
<div class="price">
<span class="now">¥{{ food.price }}</span>
<span class="old" v-if="food.oldPrice"
>¥{{ food.oldPrice }}</span
>
</div>
</div>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import BScroll from "better-scroll";
export default {
data() {
return {
scrollY: 0, // 右侧滑动的Y轴坐标 (滑动过程时实时变化)
tops: [], // 所有右侧分类li的top组成的数组 (列表第一次显示后就不再变化)
goods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "优惠",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "瑶",
description: "公主",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "安琪拉",
description: "双马尾",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "折扣",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "小乔",
description: "扇子",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "王昭君",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
name: "法师",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "小乔",
description: "扇子",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "王昭君",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "王昭君",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "王昭君",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
name: "辅助",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "瑶",
description: "公主",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "蔡文姬",
description: "kkk",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "东皇",
description: "hhh",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "孙膑",
description: "666",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
name: "射手",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "伽罗",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "后裔",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "狄仁杰",
description: "hhh",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "公孙离",
description: "666",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
name: "打野",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "李白",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "百里玄策",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "韩信",
description: "hhh",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "孙悟空",
description: "666",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
},
{
name: "坦克",
foods: [
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "亚瑟",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "项羽",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "凯",
description: "hhh",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "夏侯惇",
description: "666",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "亚瑟",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "项羽",
description: "",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "凯",
description: "hhh",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
},
{
icon: "http://liangziqi.top/meme-img/126-430.jpg",
name: "夏侯惇",
description: "666",
sellCount: 1,
rating: 100,
price: 2,
oldPrice: "6"
}
]
}
]
};
},
mounted() {
this.$nextTick(() => {
this._initScroll();
this._initTops();
});
},
computed: {
// 计算得到当前分类的下标
currentIndex() {
// 初始化和相关数据发生了变化时执行
// 得到条件数据
const { scrollY, tops } = this;
// 根据条件计算产生一个结果
const index = tops.findIndex((top, index) => {
// scrollY>=当前top && scrollY<下一个top
return scrollY >= top && scrollY < tops[index + 1];
});
// 返回结果
return index;
}
},
methods: {
// 初始化滚动
_initScroll() {
// 列表显示之后创建
new BScroll(".menu-wrapper", {
click: true
});
this.foodsScroll = new BScroll(".foods-wrapper", {
probeType: 2, // 因为惯性滑动不会触发
click: true
});
// 给右侧列表绑定scroll监听
this.foodsScroll.on("scroll", ({ x, y }) => {
console.log("scroll", x, y);
this.scrollY = Math.abs(y);
});
// 给右侧列表绑定scroll结束的监听
this.foodsScroll.on("scrollEnd", ({ x, y }) => {
console.log("scrollEnd", x, y);
this.scrollY = Math.abs(y);
});
},
// 初始化tops
_initTops() {
// 1. 初始化tops
const tops = [];
let top = 0;
tops.push(top);
// 2. 收集
// 找到所有分类的li
const lis = this.$refs.foodsUl.getElementsByClassName("food-list-hook");
Array.prototype.slice.call(lis).forEach(li => {
top += li.clientHeight;
tops.push(top);
});
// 3. 更新数据
this.tops = tops;
console.log(tops);
},
clickMenuItem(index) {
console.log(index);
// 使用右侧列表滑动到对应的位置
// 得到目标位置的scrollY
const scrollY = this.tops[index];
// 立即更新scrollY(让点击的分类项成为当前分类)
this.scrollY = scrollY;
// 平滑滑动右侧列表
this.foodsScroll.scrollTo(0, -scrollY, 300);
}
}
};
</script>
<style lang="stylus" rel="stylesheet/stylus">
bottom-border-1px($color) {
position: relative;
border: none;
&:after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background-color: $color;
transform: scaleY(0.5);
}
}
.goods {
display: flex;
position: absolute;
top: 15px;
bottom: 46px;
width: 100%;
background: #fff;
overflow: hidden;
.menu-wrapper {
flex: 0 0 80px;
width: 80px;
background: #f3f5f7;
.menu-item {
display: table;
height: 54px;
width: 56px;
padding: 0 12px;
line-height: 14px;
&.current {
position: relative;
z-index: 10;
margin-top: -1px;
background: #fff;
color: $green;
font-weight: 700;
}
.icon {
display: inline-block;
vertical-align: top;
width: 12px;
height: 12px;
margin-right: 2px;
background-size: 12px 12px;
background-repeat: no-repeat;
}
.text {
display: table-cell;
width: 56px;
vertical-align: middle;
bottom-border-1px(rgba(7, 17, 27, 0.1));
font-size: 12px;
}
}
}
.foods-wrapper {
flex: 1;
.title {
padding-left: 14px;
height: 26px;
line-height: 26px;
border-left: 2px solid #d9dde1;
font-size: 12px;
color: rgb(147, 153, 159);
background: #f3f5f7;
text-align: left;
margin: 0;
}
.food-item {
display: flex;
margin: 18px;
padding-bottom: 18px;
bottom-border-1px(rgba(7, 17, 27, 0.1));
&:last-child {
margin-bottom: 0;
}
.icon {
flex: 0 0 57px;
margin-right: 10px;
}
.content {
flex: 1;
text-align: left;
.name {
margin: 2px 0 8px 0;
height: 14px;
line-height: 14px;
font-size: 14px;
color: rgb(7, 17, 27);
}
.desc, .extra {
line-height: 10px;
font-size: 10px;
color: rgb(147, 153, 159);
}
.desc {
line-height: 12px;
margin-bottom: 8px;
}
.extra {
.count {
margin-right: 12px;
}
}
.price {
font-weight: 700;
line-height: 24px;
.now {
margin-right: 8px;
font-size: 14px;
color: rgb(240, 20, 20);
}
.old {
text-decoration: line-through;
font-size: 10px;
color: rgb(147, 153, 159);
}
}
}
}
}
}
li {
list-style: none;
}
ul {
padding: 0;
margin: 0;
}
</style>
3. 页面滚动到顶部后,往下拖拽执行关闭vue

用来模拟手势,向下,关闭弹窗 1.当前容器记录容器顶部 0 (scrollTop) 2.手势向下 3.位移量 >100
<template>
<div id="member" ref="scrollContainer">
<!-- 模块1-->
<member-xinxi />
<!-- 模块2-->
<member-xunshi />
<member-yinhuan />
<member-jiance />
<member-xstongji />
</div>
</template>
<script>
import MemberXinxi from "./userComponents/userxinxi.vue";
import MemberXunshi from "./userComponents/user-xunshi.vue";
import MemberYinhuan from "./userComponents/user-yinhuan.vue";
import MemberJiance from "./userComponents/user-jiance.vue";
import MemberXstongji from "./userComponents/user-xstongji.vue";
export default {
name: "UserDetail",
components: {
MemberXinxi,
MemberXunshi,
MemberYinhuan,
MemberJiance,
MemberXstongji
},
data() {
return {
scrollContainer:undefined,// 滚动元素
scrollTop:0,// 移动量(被滚动元素距离头部的高度)
};
},
mounted() {
// 添加滚动监听事件
this.scrollContainer = this.$refs.scrollContainer
this.scrollContainer.addEventListener('scroll', this.handleScrollEvent);
this.bindEvent();// 绑定手势
},
beforeDestroy() {
this.scrollContainer.removeEventListener('scroll', this.handleScrollEvent)
},
methods: {
// 获取页面滚动距离
handleScrollEvent (e) {
this.scrollTop = this.scrollContainer.scrollTop;// 弹窗 滚动位移量
//let scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;// 页面
//console.log(this.scrollTop)
},
// 注册 手势
bindEvent () {
this.scrollContainer.addEventListener('touchstart', (e) => {
this.touchstart(e)
})
this.scrollContainer.addEventListener('touchmove', (e) => {
this.touchmove(e)
})
},
// 获取初始位置,用于决定 位移量和 位移方向
touchstart (e) {
this.lastClientY = e.touches[0].clientY
},
/**
* 用来模拟手势,向下,关闭弹窗
* 1.当前容器记录容器顶部 0 (scrollTop)
* 2.手势向下
* 3.位移量 >100
* @param e
*/
touchmove(e){
var clientY = e.touches[0].clientY
this.drag_direction = clientY - this.lastClientY > 0 ? 'toBottom' : 'toTop'//定义向上向下
this.move_distance = Math.abs(clientY - this.lastClientY)// 位移距离
// 元素一级滚动到顶部了,现在还往下拉,认为用户是想关闭弹窗
console.log('元素现在高度,位移量'+this.scrollTop+'-------'+this.move_distance+'/-----'+this.drag_direction);
if(this.scrollTop==0 && this.drag_direction=='toBottom' && this.move_distance>100){
var data={
type:'closeDetail'
}
this.$emit('sonHandleCallback', data)
}
},
},
};
</script>
4.tab&锚点

<template>
<div id="xianlu" ref="scrollContainer">
<!-- 滑动选项 -->
<van-tabs class="tab_menu" :active="activeIndex" swipeable @click="onClickTab">
<van-tab v-for="item in tabMenuList" :key="item" :title="item.title">
<template #title>
<div class="line_horizontal_layout">
<div class="tab_menu_title">{{ item.title }}</div>
</div>
</template>
</van-tab>
</van-tabs>
<!-- 引用的组件 -->
<jichu-xinxi ref="xinxi"/>
<tai-zhang ref="taizhang"/>
<xianlu-tongdao ref="xianlu"/>
<xunshi-tongji ref="xunshi"/>
<dunshou-tongji ref="dunshou"/>
</div>
</template>
<script>
import JichuXinxi from './jichuxinxi.vue'
import TaiZhang from './taizhang.vue'
import XianluTongdao from './xianlutongdao.vue'
import XunshiTongji from './xunshitongji.vue'
import DunshouTongji from './dunshoutongji.vue'
export default {
name: 'Xianludetail',
components: { JichuXinxi, TaiZhang, XianluTongdao, XunshiTongji, DunshouTongji },
data () {
return {
scrollContainer:undefined,// 滚动元素
scrollTop:0,// 移动量(被滚动元素距离头部的高度)
activeIndex: 0,
tabMenuList: [
{id: 1, title: '基础信息', el: 'xinxi' },
{ id: 2, title: '台账统计', el: 'taizhang' },
{ id: 3, title: '线路通道', el: 'xianlu' },
{ id: 4, title: '巡视统计', el: 'xunshi' },
]
}
},
mounted () {
// 添加滚动监听事件
this.scrollContainer = this.$refs.scrollContainer
this.scrollContainer.addEventListener('scroll', this.handleScrollEvent)
this.bindEvent();// 绑定手势
},
beforeDestroy () {
this.scrollContainer.removeEventListener('scroll', this.handleScrollEvent)
},
methods: {
onClickTab (item) {
// 拿到的是下标
this.activeIndex = item
const refName = this.tabMenuList[item].el
if (!refName) return
const el = this.$refs[refName]
if (el) {
let offsetTop = el.$el ? el.$el.offsetTop : el.offsetTop
offsetTop -= 35 + 25 // 减去menu高度,向上预留25
if (offsetTop < 0) offsetTop = 0
console.log(offsetTop)
this.scrollContainer.scrollTo({
top: offsetTop,
behavior: 'smooth'
})
}
},
// 滚动事件
handleScrollEvent () {
const arr = []
this.tabMenuList.forEach((item, index) => {
const refName = item.el
const el = this.$refs[refName]
if (el) {
let offsetTop = el.$el ? el.$el.offsetTop : el.offsetTop
offsetTop -= 35 + 25 // 减去menu高度,向上预留25
if (offsetTop < 0) offsetTop = 0
arr.push({
offsetTop,
index
})
}
})
this.scrollTop = this.scrollContainer.scrollTop
// 对滚动的值进行判断
if (this.scrollTop <= arr[0].offsetTop) {
this.activeIndex = arr[0].index
} else if (this.scrollTop <= arr[1].offsetTop) {
this.activeIndex = arr[1].index
} else if (this.scrollTop <= arr[2].offsetTop) {
this.activeIndex = arr[2].index
} else if (this.scrollTop <= arr[3].offsetTop) {
this.activeIndex = arr[3].index
} else if (this.scrollTop <= arr[4].offsetTop) {
this.activeIndex = arr[4].index
} else if (this.scrollTop <= arr[5].offsetTop) {
this.activeIndex = arr[5].index
}
},
// 注册 手势
bindEvent () {
this.scrollContainer.addEventListener('touchstart', (e) => {
this.touchstart(e)
})
this.scrollContainer.addEventListener('touchmove', (e) => {
this.touchmove(e)
})
},
// 获取初始位置,用于决定 位移量和 位移方向
touchstart (e) {
this.lastClientY = e.touches[0].clientY
},
/**
* 用来模拟手势,向下,关闭弹窗
* 1.当前容器记录容器顶部 0 (scrollTop)
* 2.手势向下
* 3.位移量 >100
* @param e
*/
touchmove(e){
var clientY = e.touches[0].clientY
this.drag_direction = clientY - this.lastClientY > 0 ? 'toBottom' : 'toTop'//定义向上向下
this.move_distance = Math.abs(clientY - this.lastClientY)// 位移距离
// 元素一级滚动到顶部了,现在还往下拉,认为用户是想关闭弹窗
console.log('元素现在高度,位移量'+this.scrollTop+'-------'+this.move_distance+'/-----'+this.drag_direction);
if(this.scrollTop==0 && this.drag_direction=='toBottom' && this.move_distance>100){
var data={
type:'closeDetail'
}
this.$emit('sonHandleCallback', data)
}
},
}
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/mapdetail/style.css";
</style>
时间轴

<!--
* @Descripttion:
* @Author: cuixu
* @Date: 2022-11-01 14:56:00
-->
<template>
<!--线路区段组件-->
<div class="mapLine" :class="{check:itemObj.type != 'line'}">
<div class="mapLine-top">
<img src="@/assets/mapIcons/top_11.png" alt=""/>
<span>线路区段</span>
<img class="mapLine-top-dian" src="@/assets/mapIcons/dian.png" alt=""/>
</div>
<!-- 内容 -->
<div class="line-steps pr" ref="time_warp" >
<el-steps :space="100" align-center id="steps">
<el-step v-for="(item, index) in list" :key="index" :title="item.name">
<i class="build stepIcon" slot="icon" :title="item.name">{{substrfilter(item.name,1)}}</i>
</el-step>
</el-steps>
<!-- 左右 方向箭
左侧箭头-->
<i class="iconfont icon-zhankai2 pa key-left key-direction" v-if="showStart" @click="move('subtract')"></i>
<i class="iconfont icon-zhankai1 pa key-right key-direction" v-if="showEnd" @click="move('add')"></i>
</div>
</div>
</template>
<script>
import { wayPointForTeam } from '@/api/ydxj/base/basLine'
import { substrfilter } from '@/utils/common.js'
export default {
props: {
itemObj: {
type: Object,
default: () => ({})
}
},
data () {
return {
list: [],
time_width:105,// Steps 步骤条 单个宽带
showStart: false,
showEnd: false,
timeSelector:undefined,// steps 进步器 对象,因为无法通过 ref 所以只能 通过 this.$el.querySelector('#steps')
maxOffset:0,// 最大偏移量,用于 判断是否 拖拽到最左侧
}
},
created () {
this.loadData()
},
// activated () { this.loadData() },
methods: {
loadData () {
this.list = [];
this.showStart=false;// 重现隐藏 左右 方向箭头
this.showEnd=false;
if (!this.itemObj.lineId) {
return
}
if (this.itemObj.type == 'line') {
// 加载线路
wayPointForTeam({ lineId: this.itemObj.lineId,towerId:this.itemObj.towerId }).then(response => {
this.list = response.data;
this.initTimes();
})
}
},
substrfilter (value, length) {
return substrfilter(value, length)
},
initTimes(){
this.$nextTick(()=>{
let warp_width = this.$refs.time_warp.clientWidth-70;// 父容器 宽度
let item_width = this.time_width*this.list.length;// 子容器宽度
this.maxOffset = item_width - warp_width;
if(warp_width<=item_width){// 说明超出容器,需要放开 方向箭头,用于指示方向
this.showEnd=true;// 数据加载完成后,并且 超长容器宽度,此时需要显示 右侧 方向箭头
}
this.timeSelector = this.$el.querySelector('#steps');
})
},
move(type){
let num=this.time_width;
this.showStart=true;
this.showEnd=true;
if(type=='subtract'){
num=-this.time_width;
}
let nowLeft = this.timeSelector.scrollLeft+num;//
this.timeSelector.scrollTo({
left: nowLeft,
behavior: 'smooth'
})
// 因为scrollTo 当前添加动画,导致 修改属性是异步的不会立刻生效,所以 下面直接使用this.timeSelector.scrollLeft进行判断 具有滞后性
if(nowLeft<=10){// 移动到开始
this.showStart=false;
}
if(this.maxOffset<=nowLeft){// 移动到 结束了
this.showEnd=false;
}
},
}
}
</script>
<style lang="scss" scoped>
.mapLine{
overflow: hidden;
transition: all 0.6s;
height: 13.5vh;
&.check{
height: 0;
}
}
/deep/ .el-step__title {
font-size: 12px;
margin-top: 3px;
color: #fff;
}
/deep/ .el-step__line {
background: #012e43;
top: 17px !important
}
.line-steps {
padding: 10px 35px 0 35px;
height: 10vh;
display: flex;
align-items: center;
justify-content: center;
.el-steps {
display: block;
height: 100%;
overflow: hidden;
display: flex;
.el-step {
display: inline-block;
width: 105px;
padding-top: 6px;
max-width: 50% !important;
min-width: 105px;
.el-step__main {
.el-step__title {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
/deep/ .el-step__icon.is-text {
border-radius: 50%;
border: 2px solid #00fffd !important;
border-color: inherit;
width: 36px;
height: 36px;
background: #012e43;
color: #fefefe;
cursor:default;
}
/* .stepIcon {
width: 36px;
height: 36px;
background-size: 100% 100%;
position: absolute;
}*/
.build {
font-style:normal;
/*background-image: url("~@/assets/mapIcons/yun.png");*/
}
}
/*左右方向箭头*/
.key-direction{
top: 23%;
font-size: 20px;
color: #54f3f3;
}
.key-left{
left: 14px;
}
.key-right{
right: 14px;
}
.key-right {
-webkit-animation: arrow .8s .5s ease-in-out infinite alternate;
animation: timebs_animation .8s .5s ease-in-out infinite alternate;
}
@-webkit-keyframes timebs_animation {
100% {
-webkit-transform: translateX(-10%);
opacity: 0.5;
}
0% {
-webkit-transform: translateX(30%);
opacity: 0;
}
}
@keyframes timebs_animation {
100% {
transform: translateX(-10%);
opacity: 0.5;
}
0% {
transform: translateX(30%);
opacity: 0;
}
}
.key-left {
-webkit-animation: arrow .8s .5s ease-in-out infinite alternate;
animation: timebs_animation .8s .5s ease-in-out infinite alternate;
}
</style>
element中 仿$confirm 确认框
https://blog.csdn.net/weixin_36617251/article/details/112585426
1. 创建 Comfirm.js 文件
import Vue from 'vue';
import confirm from '../Comfirm.vue';
let confirmConstructor = Vue.extend(confirm);
let theConfirm = function (content) {
return new Promise((res, rej) => {
//promise封装,ok执行resolve,no执行rejectlet
let confirmDom = new confirmConstructor({
el: document.createElement('div')
})
document.body.appendChild(confirmDom.$el); //new一个对象,然后插入body里面
confirmDom.content = content; //为了使confirm的扩展性更强,这个采用对象的方式传入,所有的字段都可以根据需求自定义
confirmDom.ok = function () {
res()
confirmDom.isShow = false
}
confirmDom.close = function () {
rej()
confirmDom.isShow = false
}
})
}
export default theConfirm;
2. Confirm.vue
<template>
<!-- 自定义确认弹窗样式 -->
<el-dialog width="600px" :title="content.title" :visible.sync="content.show" v-if="isShow">
<span>{{ content.message }}</span>
<div slot="footer" class="dialog-footer">
<el-button @click="close">
取 消
</el-button>
<el-button type="primary" @click="ok">
确 定
</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
data() {
return {
// 弹窗内容
isShow: true,
content: {
title: "",
message: "",
data: "",
show: false
}
};
},
methods: {
close() {
},
ok() {
}
}
};
</script>
<style>
</style>
3. 在main.js中引入
import confirm from '@/confirm.js'
Vue.prototype.$confirm = confirm;
4. 调用
this.$confirm({ title: "删除", message: "确认删除该文件吗?", show: true })
.then(() => {
//用户点击确认后执行
}) .catch(() => {
// 取消或关闭
});
vue 记录用户修改的字段
思路: 获取初始值是复制一份(用于比对用户修改),记录需要监听的字段,通过 mixins 混入,然后将具体的监听方法提取出来,作为公共方法
新建 watchFromMixins.js
/**
* 班组管理中,需要记录 用于修改的字段,通过 mixins 注入需要的的 组件中
*/
import {isEmptyObj} from '@/utils/common'
export default {
data() {
return {
form:{},
origForm: {}, // form 对象备份-用于比对属性是否变更
watchField: [], // 需要监听的字段
needChangeField:'changeDesc' // 默认 赋值的字段,防止 页面不是这个字段,如果不一致,可在需要的组件中指定
}
},
created() { },
destroyed() { },
methods: {
/**
* 格式化 显示的数值
* @param value:当前对应的值
* @param options: 针对下拉选和字典 (对于下拉选和字典 存储数据的字段都不一致,该方法默认是从字典中获取数据,如果不是,则指定对应的属性名)
* options{
* dataName:"statusList", 下拉选\字典 存储的字典名称
* valueName:"dictvalue",:默认是 字典 dictValue
* labelName: "dictLabel":默认是字典的 dictLabel
* }
* @returns {string}
*/
getFieldName (value,options) {
if(!value && value != 0){
value = '空';
}else{
// 考虑字典,下拉选,自动 由vale 值获取label
if(options && this[options.dataName] && this[options.dataName].length > 0){
value = this.getSelectLabel(options, value)
}
}
return value
},
// 针对下拉选,和字典,由vale 值获取label
getSelectLabel (options, value) {
const valueField = options.valueName || 'dictValue'; //
const labelField = options.valueName || 'dictLabel';
this[options.dataName].some((item, index, self) => {
if (item[valueField] === value) {
value = item[labelField];
return true;// 查询到终止循环
}
})
return value
},
// 重新组装 changeDesc
_updateFormData() {
const tempForm = JSON.parse(JSON.stringify(this.form))
if(tempForm.changeDesc2){
tempForm.changeDesc += '\n'+this.form.changeDesc2
tempForm.changeDesc2 = '';
}
return tempForm;
}
},
watch: {
form: {
handler(newVal, oldVal) {
if(newVal && !isEmptyObj(newVal) && this.origForm.id) {
const messageArr = []; //组装的提示信息
if (!this.watchField || this.watchField.length ==0) {
return;
}
this.watchField.forEach( ({field,fieldName,options}) => {
if(this.form[field] != this.origForm[field]){
if (!this.origForm[field] && this.form[field] == '') { // 针对的是 初始值为 undefined,修改后又清除的场景
return;
}
const newFieldName = this.getFieldName(this.origForm[field],options); // 新字段值
const oldFieldName = this.getFieldName(this.form[field],options);
const mes = `${fieldName}: 由 "${newFieldName}" 变更为 "${oldFieldName}"`
messageArr.push(mes);
}
})
this.$set(this.form, this.needChangeField, messageArr.join(";"))
}
},
deep:true // 深度监听
}
}
}
<script>
import mixins from '@/mixin/watchFromMixins'
export default {
mixins: [mixins], // 通过 mixins(混入)将 监听字段变更 提出到功能js类中
data () {
return {
form: {},
needChangeField:'changeDesc'
watchField: [
{ field: 'sbStatusName', fieldName: '设备状态' },{ field: 'bqArea', fieldName: '便桥行政区划' },
{ field: 'bqAddr', fieldName: '便桥位置' },{ field: 'bqClass', fieldName: '便桥种类' }
], // 需要监听的字段
}
}
methods: {
loadData (item) {
item = item || {}
this.getMainData(item)
},
getMainData (item) {
// 后台查询数据
getDataInfo(item).then(response => {
this.form = response.data
this.initOtherData() // 数据获取后的一些默认赋值 操作
})
},
// 数据获取后的一些默认赋值 操作
initOtherData () {
if (this.form.sbStatus == 1) {
this.form.sbStatusName = '在用'
} else {
this.form.sbStatusName = '停用'
}
// 这部很重要,如果不默认赋值,可能会导致该字段无法修改
this.form.changeDesc = ''; // 初始化,否则data不会监听数值变化,同样,会导致 mixins 混入赋值后,用户无法修改该字段
this.origForm = JSON.parse(JSON.stringify(this.form))
},
}
}
</script>
vue 水平tab ,根据内容自动定位
初始化时根据自定义的index,默认选择指定的tab,同时随着内容滚动,默认选中指定tab,并且

运行管理towerParameter.vue -父页面
<!--
* @Descripttion:
* @Author: cuixu
* @Date: 2022-10-19 08:51:10
-->
<template>
<div class="page">
<!--页面头部-->
<PageHeader :title="title" position="center"> </PageHeader>
<div class="towerContainer">
<!-- 头部滑动导航 -->
<towerTopScroll ref="towerTopScrollRef" :topTabType="topTabType" :bottomTabIndex="bottomTabIndex" @towerTopTabclick="towerTopclick" />
<div class="tower-scroll" ref="cont" id="tower">
<!-- 基本信息 -->
<baseInfo id="tabs0" :name="comName" :item-obj="itemObj" ref="teamBase" @updateTowerInfo="updateTowerInfo" />
<!-- 隐患信息 -->
<hiddenDangerInfo id="tabs1" :name="comName" :item-obj="itemObj" ref="hiddenDanger" />
<!-- 缺陷信息 -->
<defectsInfo id="tabs2" :name="comName" :item-obj="itemObj" ref="defects" />
<!-- 通道环境 -->
<channelEnvironment id="tabs3" :name="comName" :item-obj="itemObj" ref="basTd" />
<!-- 附属设施 -->
<ancillaryFacilities id="tabs4" :name="comName" :item-obj="itemObj" ref="basFs" />
<!-- 六防信息 -->
<sixPrevention id="tabs5" :name="comName" :item-obj="itemObj" ref="basLf" />
<!-- 检测信息 -->
<detection id="tabs6" :name="comName" :item-obj="itemObj" ref="basJc" />
<!-- 三跨/交跨 -->
<threeSpans id="tabs7" :name="comName" :item-obj="itemObj" ref="threeSpans" />
</div>
</div>
</div>
</template>
<script>
import PageHeader from "@/components/page-header/PageHeader.vue";
import common from "../../../public";
import towerContent from "./components/towerContent";
import towerTopScroll from "./components/towerTopScroll";
import baseInfo from "./components/baseInfo";
import hiddenDangerInfo from "./components/hiddenDangerInfo";
import defectsInfo from "./components/defectsInfo";
import channelEnvironment from "./components/channelEnvironment";
import ancillaryFacilities from "./components/ancillaryFacilities";
import sixPrevention from "./components/sixPrevention";
import detection from "./components/detection";
import threeSpans from "./components/threeSpans";
import { mapState, mapMutations } from 'vuex'
// import selectorTowerPopup from '@/components/bac-selector/selectorTowerPopup'
import bottomBtton from "@/components/bottomButton/index.vue";
import { Toast, Dialog } from 'vant';
import { getDicts } from '../../../net/commonRequest'
export default {
name: "towerParameter",
components: {
PageHeader,
towerContent,
towerTopScroll,
baseInfo,
hiddenDangerInfo,
defectsInfo,
channelEnvironment,
ancillaryFacilities,
sixPrevention,
detection,
threeSpans,
bottomBtton,
// selectorTowerPopup
},
data() {
return {
// 页面名称
title: "GT台账",
//当前导航名称
comName: "",
// 头部导航类型
topTabType: "tipTab1",
// 头部导航当前下标
bottomTabIndex: 0,
showUserSelect: false,
towerUpdateInfo: {},
userList: [],
// towerList: [],
jyzInfoList: [],
//已删除的绝缘子信息
jyzInfoList_DEL: [],
selectDataList: [],
//绝缘子类型
typeOptions: [],
//绝缘子组装形式
zzTypeOptions: [],
//绝缘子材料
jyzClOptions: [],
userCascaderValue:'',
guarderInfoId: "",
jyzIndex: "",
currentUser: this.$store.state.userInfo.userId,
originQzUserId: undefined,
dictPopupShow: false,
canUpdateQzhxy: true,//目前取消不能修改群众护线员信息的限制
dictFieldName: {
text: 'dictLabel'
},
// canSelectTower: false,
itemObj: {}, // 业务参数-父页面传递过来的 GT信息
fieldNames: {
text: "label",
value: "id",
children: "children",
},
list: [ ],
//滚动公共class
arrDom: null,
scrollTop: 0,
towerInfoUpdatePop: false,
needActivated: false, // 是否需要 走 activated ,目前 activated-是为了解决 局部刷新和 由详情界面返回滚动到原来的位置
needHandleScroll: false // 是否需要 执行滚动(主要是因为初始化,模拟点击头部tab,但是会触发 handleScroll 事件,导致)
};
},
computed: {
...mapState(['cachePageList']),// 组件访问 State 中数据的第二种方式 也可以直接用this.$store.state.count
},
mounted() {
this.needActivated = false;
let data ={};
let index = 0; // 用来 默认 选中 指定tab
// 从GT数据点击跳转过来
if(this.$route.query.index){ // 说明需要跳转到 当前指定的模块
data={
name:this.$route.query.name
}
index = Number(this.$route.query.index)
}else{
data={ name:'baseInfo'}; // 初始化时,默认 选择的是 GT基本信息
this.resetScrollTop(0); // 重置锚点定位,否在位置不对
}
this.towerTopclick(data,index)
// 监听页面滚动
var tower = document.getElementById("tower");
tower.addEventListener("scroll", this.handleScroll);
// 每个滚动模块
this.arrDom = document.getElementsByClassName("towerBaseBox");
setTimeout(() => {
this.needActivated = true;
}, 500);
},
created() {
this.itemObj = this.$route.query;
// 权限
let dataScope = common.getUserMaxDataScope();
if (dataScope > 1) {
this.canUpdateQzhxy = true;
}
},
activated() {
if(!this.needActivated){
return;
}
// 局部刷新,为了解决 修改后,GT台账里面数据更新
this.partialRefresh();
// 锚点定位,解决,从详情界面返回还能定位到之前的位置
this.resetScrollTop(this.scrollTop);
},
// 监听离开,如果返回父页面则清除当前组件的缓存
beforeRouteLeave: function (to, from, next) {
this.needActivated = true;
if (to.name == "lineRunning") { // 返回上一级页面需要 清除当前页面的缓存
// 因为目前当前页面 路由中配置的 name 和组件name 没对应上,否则可有直接用 from.name
this.setCachePageListRemoveMutation("towerParameter");// 移除 当前组件的缓存
this.setNeedUpdateModulesRemoveAllMutation();// 清除一下局部缓存
}
next();
},
deactivated() {
console.log('组件被销毁');
},
methods: {
...mapMutations(['setCachePageListRemoveMutation', 'setNeedUpdateModulesRemoveAllMutation']),// 将指定的 mutations 函数,映射为当前组件的 methods 函数
// 页面初始化参数
loadData() { },
/**
* 滚动监听事件
*/
handleScroll() {
if(!this.needHandleScroll){ // 标识当前真正进行 模拟点击tab 定位,不执行,由高度确定 选择tab 的index
return;
}
const current_offset_top = this.$refs.cont.scrollTop + this.arrDom[0].offsetTop; // 需要去掉 头部和tab区域高度
this.scrollTop = this.$refs.cont.scrollTop;
for (let i = 0; i < this.arrDom.length; i++) {
if (i < 7) {
if (this.arrDom[i].offsetTop <= current_offset_top && current_offset_top < this.arrDom[i + 1].offsetTop) {
// 根据滚动距离判断应该滚动到第几个导航的位置
this.bottomTabIndex = i;
break;
}
} else {
this.bottomTabIndex = 7;
}
}
this.$refs.towerTopScrollRef.locationTab();
},
// 点击头部导航按钮
towerTopclick(item, data) {
this.needHandleScroll = false;
console.log('towerTopclick')
this.bottomTabIndex = data;
this.comName = item.name;
let id = `#tabs${data}`;
this.$nextTick(() => {
document.querySelector(id).scrollIntoView({
behavior: "instant", // 定义过渡动画 instant立刻跳过去 smooth平滑过渡过去
block: "start", // 定义垂直滚动方向的对齐 start顶部(尽可能) center中间(尽可能) end(底部)
inline: "start", // 定义水平滚动方向的对齐
});
setTimeout(() => {
this.needHandleScroll = true; // 模拟点击tab滚动完成,开始监听 滚动和tab
}, 500);
});
},
resetScrollTop (scrollTop) {
console.log('定位-'+scrollTop)
this.$nextTick(() => {
this.$refs.cont.scrollTop = scrollTop;
});
},
// 局部 刷新 页面
partialRefresh() {
let needUpdateModules = this.$store.state.needUpdateModules;
if (needUpdateModules && needUpdateModules.length > 0) {
needUpdateModules.forEach(modules => {
try {
this.$refs[modules].onLoad()
} catch (e) {
console.log(e);
console.log("局部刷新异常")
}
})
this.setNeedUpdateModulesRemoveAllMutation();
}
},
updateTowerInfo(item) {
this.towerUpdateInfo = { userId: item.ownerQzId, userName: item.ownerQzName, guarderType: "2", lineId: item.lineId, eqTowerId: item.eqTowerId,towerId: item.towerId };
this.originQzUserId = item.ownerQzId;
this.getJyzData(item);
},
//获取GT绝缘子信息
getJyzData(item) {
let that = this;
let options = {
method: "post",
url: "/teams/basInsulator/getJueyuanziByEqTowerId",
headers: { 'Content-Type': 'application/json;charset=utf-8' },
params: { eqTowerId: item.eqTowerId, sbStatus: '1' },
appservercode: "teams"
};
this.$commonRequest(options, function (res) {
if (res && res.code == 200) {
if (res.data && res.data.length > 0) {
that.jyzInfoList = res.data;
that.initDictData();
}
} else {
Toast.fail({
message: "查询绝缘子信息出错了,错误信息:" + res.msg,
duration: 5000,
});
}
// that.getHuxianyuanData(item);
that.userTreeselect();
that.towerInfoUpdatePop = true;
});
},
},
};
</script>
<style scoped lang="scss">
.content {
padding: 16px 16px 160px;
}
.needUpdate {
color: #1c71fb !important;
font-weight: 600;
}
:deep {
.towerJyzContent .van-field__label {
width: unset !important;
}
.bottomBtton .bottomBtton-box .bottomBtton-right {
margin-left: unset !important;
}
}
</style>
tab区域
<!--
* @Descripttion:
* @Author: cuixu
* @Date: 2022-11-03 16:58:46
-->
<template>
<div ref="tabUl" class="type-ul">
<div :id="'tabli'+index" class="type-ul-li" :class="{ typeActive: index == bottomTabIndex }"
v-for="(item, index) in tabbars" :key="index" @click="typeClick(item,index)">
<img :src="index == bottomTabIndex ? item.active : item.normal" alt=""/>
<div>{{ item.title }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
topTabType: {
type: String,
},
bottomTabIndex: {
type: Number,
},
},
data () {
return {
// GT台账
tabbars: [
{
name: "baseInfo",
title: "基本信息",
normal: require("../../../../assets/teamImgs/towerTopTab/tab1.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab1_active.png"),
},
{
name: "hiddenDangerInfo",
title: "隐患信息",
normal: require("../../../../assets/teamImgs/towerTopTab/tab3.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab3_active.png"),
},
{
name: "defectsInfo",
title: "缺陷信息",
normal: require("../../../../assets/teamImgs/towerTopTab/tab2.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab2_active.png"),
},
{
name: "channelEnvironment",
title: "通道环境",
normal: require("../../../../assets/teamImgs/towerTopTab/tab4.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab4_active.png"),
},
{
name: "ancillaryFacilities",
title: "附属设施",
normal: require("../../../../assets/teamImgs/towerTopTab/tab5.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab5_active.png"),
},
{
name: "sixPrevention",
title: "六防信息",
normal: require("../../../../assets/teamImgs/towerTopTab/tab6.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab6_active.png"),
},
{
name: "detection",
title: "检测",
normal: require("../../../../assets/teamImgs/towerTopTab/tab7.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab7_active.png"),
},
{
name: "threeSpans",
title: "三跨/交跨",
normal: require("../../../../assets/teamImgs/towerTopTab/tab8.png"),
active: require("../../../../assets/teamImgs/towerTopTab/tab8_active.png"),
},
]
};
},
created () {},
activated () {
/**
* 将当前选中的tab卡片 滚动到可视区域 (2个场景下会触发)
* 1.需要跳转到指定的tab
* 2.详情界面返回(页面已经实现缓存,但是滚动位置需要自己手动设置)
*/
this.$nextTick(() => {
this.scrollCheckDom()
});
},
methods: {
// 点击选中
typeClick (item, index) {
this.currentIndex = index;
this.$emit("towerTopTabclick", item, index);
},
/**
* 将当前选中的tab卡片 滚动到可视区域(由父页面调用)
* 父页面 滚动内容区域,默认选中当前对于的 tab选项卡
* 计算是否在 可视区域内,否则触发 scrollCheckDom 方法
*/
locationTab () {
setTimeout(() => {
const checkDm = this.$el.querySelector(`#tabli${this.bottomTabIndex}`).getBoundingClientRect();//this.$refs.wdzb_detail_item_wrap;
const presentWidth = checkDm.left + checkDm.width; // 当前选中的 tab,最大宽度,如果这个值大于 父容器,则表示 当前 需要滚动否则元素在最右侧 看不到
// checkDm.left < 0 表示当前 选中的tab,不在可视区域内了
if (this.$refs.tabUl.getBoundingClientRect().width >= presentWidth && checkDm.left > 0){
return;
}
this.scrollCheckDom();
}, 300);
},
// 将当前选中的tab卡片 滚动到可视区域
scrollCheckDom () {
document.querySelector(`#tabli${this.bottomTabIndex}`).scrollIntoView({
behavior: "instant", // 定义过渡动画 instant立刻跳过去 smooth平滑过渡过去
block: "start", // 定义垂直滚动方向的对齐 start顶部(尽可能) center中间(尽可能) end(底部)
inline: "start", // 定义水平滚动方向的对齐
});
}
}
};
</script>
<style scoped lang="scss">
.type-ul {
position: fixed;
// bottom: 0;
background: #f5f5f5;
// height: 140px;
// display: flex;
// align-items: center;
padding: 20px 0 0 0;
overflow: hidden;
overflow-x: auto;
white-space: nowrap;
width: 100%;
// box-shadow: 0px -2px 0px 0px #efefef;
z-index: 1000;
&-li {
display: inline-block;
// padding: 0 20px;
width: 135px;
color: #999999;
font-size: 22px;
img {
width: 64px;
height: 64px;
}
}
.typeActive {
color: #333333;
}
}
.type-ul-li{
transition: all 0.6s;
}
</style>
局部刷新实现
场景说明 :父页面 --> list页面--> 详情界面
list页面存在多个模块,现在需要实现如果 修改了单个模块,值跟新单个模块的数据
实现: 通过vuex记录 需要缓存的页面,然后手动跟新这个对象,
1. 父页面跳转到 list页面 -- 缓存当前list页面(解决list页面跳转详情界面在回来list页面不会重新加载)
2.详情界面修改跳转回 list页面 --- 局部刷新
3. list页面返回 父页面 --- 清除缓存

store/index.js
import {
createStore
} from 'vuex'
/*
vuex存储在内存,localstorage(本地存储)则以文件的方式存储在本地,永久保存;sessionstorage( 会话存储 ) ,临时保存.
localStorage和sessionStorage只能存储字符串类型,对于复杂的对象可以使用ECMAScript提供的JSON对象的stringify和parse来处理
Unexpected token u in JSON at position 0
*/
export default createStore({
state: {
// 需要缓存的页面, 需要注意和路由中配置的name和path没关系
cachePageList:['Message', 'Map', 'Work', 'Find', 'My', 'Hiddendanger', 'Defects', 'needUploadDataList', 'ShojamRecord'], // 需要缓存的页面对象 在 index.vue 中定义的
needUpdateModules:[]// 页面缓存,会导致数据不能及时更新,目前再班组管理中,需要 用到局部刷新,所以定义了 该,用于记录 需要刷新的模块
},
mutations: {
// 需要缓存的页面
setCachePageListMutation(state, payload) {
state.cachePageList = payload;
},
setCachePageListAddMutation(state, payload) {
if(state.cachePageList && state.cachePageList.indexOf(payload)==-1){
state.cachePageList.push(payload)
}
},
setCachePageListRemoveMutation(state, payload) {
if(state.cachePageList && state.cachePageList.indexOf(payload)>=-1){
state.cachePageList = state.cachePageList.filter((item) => item !== payload);
}
},
//记录需要局部更新 的模块--用于缓存页面局部刷新
setNeedUpdateModulesMutation(state, payload) {
state.needUpdateModules = payload;
},
//添加需要 局部更新的模块
setNeedUpdateModulesAddMutation(state, payload) {
if(state.needUpdateModules && state.needUpdateModules.indexOf(payload)==-1){
state.needUpdateModules.push(payload)
}
},
//删除需要 局部更新的模块
setNeedUpdateModulesRemoveMutation(state, payload) {
if(state.needUpdateModules && state.needUpdateModules.indexOf(payload)>=-1){
state.needUpdateModules = state.needUpdateModules.filter((item) => item !== payload);
}
},
setNeedUpdateModulesRemoveAllMutation(state, payload) {
state.needUpdateModules = [];
},
},
// Action 触发 actions 异步任务时携带参数:
actions: {
setCachePageListActions(context, payload) {
context.commit('setCachePageListMutation', payload)
},
},
modules: {
}
})
父页面跳转
gotoDetail(type, item, path) {
this.setCachePageListAddMutation("towerParameter");
this.$router.push({ path: path, query: itemObj });
},
list 页面
// 监听离开,如果返回父页面则清除当前组件的缓存
beforeRouteLeave: function (to, from, next) {
this.needActivated = true;
if (to.name == "lineRunning") { // 返回上一级页面需要 清除当前页面的缓存
// 因为目前当前页面 路由中配置的 name 和组件name 没对应上,否则可有直接用 from.name
this.setCachePageListRemoveMutation("towerParameter");// 移除 当前组件的缓存
this.setNeedUpdateModulesRemoveAllMutation();// 清除一下局部缓存
}
next();
},
activated() {
if(!this.needActivated){
return;
}
// 局部刷新,为了解决 修改后,GT台账里面数据更新
this.partialRefresh();
// 锚点定位,解决,从详情界面返回还能定位到之前的位置
this.resetScrollTop(this.scrollTop);
},
mounted() {
this.needActivated = false;
if(this.$route.query.index){ // 说明需要跳转到 当前指定的模块
xxxx
}else{
xxxxx
this.resetScrollTop(0); // 重置锚点定位,否在位置不对
}
// 监听页面滚动
var tower = document.getElementById("tower");
tower.addEventListener("scroll", this.handleScroll);
},
methods: {
...mapMutations(['setCachePageListRemoveMutation', 'setNeedUpdateModulesRemoveAllMutation']),// 将指定的 mutations 函数,映射为当前组件的 methods 函数
// 局部 刷新 页面
partialRefresh() {
let needUpdateModules = this.$store.state.needUpdateModules;
if (needUpdateModules && needUpdateModules.length > 0) {
needUpdateModules.forEach(modules => {
try {
this.$refs[modules].onLoad()
} catch (e) {
console.log(e);
console.log("局部刷新异常")
}
})
this.setNeedUpdateModulesRemoveAllMutation();
}
},
resetScrollTop (scrollTop) {
console.log('定位-'+scrollTop)
this.$nextTick(() => {
this.$refs.cont.scrollTop = scrollTop;
});
},
/**
* 滚动监听事件
*/
handleScroll() {
this.scrollTop = this.$refs.cont.scrollTop;
},
}
详情界面
submit() {
this.updateFilePath(); // 附件路径处理
// GT验证,因为权限问题,会出现,当前页面GT没有选中,此时要给提示
if (!this.form.towerId) {
Toast("请选择GT");
return;
}
this.$formValidation(this.$refs.form).then((res) => {
// 验证通过
this.$commonRequestJSON("post", "/teams/basTdBuild/saveData", this.form, (response) => {
if (response.code == 200) {
Toast("操作成功");
this.setNeedUpdateModulesAddMutation('basTd');
this.$router.go(-1);
} else {
Toast(response.msg);
}
});
});
console.log(this.form);
},
pdf 预览
1. vue-pdf
针对只预览 pdf场景
npm install --save vue-pdf
<template>
<div class="whp100" :key="pdfIndex">
<pdf ref="pdf" v-for="i in numPages" :key="i" :src="pdfSrc" :page="i"></pdf>
</div>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components:{ pdf },
data(){
return {
pdfSrc:"http://image.cache.timepack.cn/nodejs.pdf",
numPages: null, // pdf 总页数
pdfIndex: '', // 解决跟换src 不生效问题
}
},
mounted() {
this.getNumPages()
},
methods: {
// 计算pdf页码总数
getNumPages() {
let loadingTask = pdf.createLoadingTask(this.pdfSrc)
loadingTask.promise.then(pdf => {
this.numPages = pdf.numPages
}).catch(err => {
console.error('pdf 加载失败', err);
})
},
},
// 切换pdf
switchPdf(url){
this.pdfIndex++; // 不加可能会出现一些异常
this.pdfSrc = url;
this.getNumPages();
}
}
</script>
上面的方式好像切换时页面总数计算会有问题
<pdf ref="pdfRef" :src="pdfSrc" v-for="i in numPages" :page="i" :key="i" @num-pages="setNumPages" style="width: 100%; height: auto;" ></pdf>
initPdfView (url) {
this.pdfIndex++;
this.numPages = 1;
this.pdfSrc = url;
},
// 就目前看,肯能也有点问题,setNumPages 会被调用多次
setNumPages (numPages) {
console.log('PDF 文件总页数为:', numPages)
if (numPages) {
this.numPages = numPages;
}
},
拿到总页数
<template>
<div>
<pdf :src="pdfSrc" :key="pdfKey" @num-pages="setNumPages"></pdf>
<div>当前 PDF 共 {{ numPages }} 页</div>
<button @click="loadNewPdf">加载新的 PDF 文件</button>
</div>
</template>
<script>
export default {
data () {
return {
pdfSrc: 'https://cdn.mozilla.net/pdfjs/tracemonkey.pdf',
pdfKey: 1,
numPages: 0
}
},
methods: {
initPdfView (url) {
console.log(url)
this.pdfSrc = url
this.pdfKey += 1
},
setNumPages (numPages) {
console.log('PDF 文件总页数为:', numPages)
this.numPages = numPages
},
loadNewPdf () {
const url = 'https://cdn.mozilla.net/pdfjs/helloworld.pdf'
this.initPdfView(url)
}
}
}
</script>
vue中使用iframe
<iframe v-if="popupItem.url" id="iframe" ref="iframe" :src="popupItem.url" frameborder="0" width="100%" height="100%" scrolling="no"></iframe>
自定义日历
周和月
效果图

// 循环 指定年份范围,遍历每个月组织 周日历 数据
loopYearsAndMonths () {
const startDate = dayjs(this.minDate) //dayjs(`${this.minDate}-01-01`)
const endDate = dayjs(this.maxDate)//dayjs(`${this.maxDate}-12-01`)
const weekDate = []
let currentDate = startDate
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'month')) {
const year = currentDate.year()
const month = currentDate.month() + 1
const weekDateByMonth = getWeeksDate(year, month,this.theme)
weekDate.push(...weekDateByMonth)
currentDate = currentDate.add(1, 'month')
}
return weekDate
},
getWeeksDate (year, month, dataType) {
const firstDayOfMonth = dayjs(`${year}-${month}-01`)
const firstDayOfNextMonth = firstDayOfMonth.add(1, 'month')
let startDate = firstDayOfMonth.startOf('week').day(1) // 将startDate设置为下一个周一
const weeks = []
let weekCount = 1
let startDateStr = ''//每周的开始日期
let endDateStr = ''//每周的开始日期
while (startDate.isBefore(firstDayOfNextMonth)) {
const endDate = startDate.add(6, 'day') // 将 endDate 设置为 startDate 加 6 天,即本周的最后一天(周日)
const text = `${year}年${month}月第${weekCount}周`
// 为了保证每走开始和结束 日期不跨月,需要重现进行计算
//1. 验证当前 周 开始的第一天
startDateStr = startDate.format('YYYY-MM-DD')// 本周第一天
// 每周的第一天如果不在本月,则本月的第一天
if (!startDate.isSame(firstDayOfMonth, 'month')) {
startDateStr = firstDayOfMonth.format('YYYY-MM-DD')
}
//2. 验证每周的最后一天是否在本月,如果不在则取本月最后一天
endDateStr = endDate.format('YYYY-MM-DD')// 本周最后一天
// 本周最后一天,如果 不在本月,则说明跨域了,取本月的最后一天
if (!endDate.isSame(firstDayOfMonth, 'month')) {
endDateStr = firstDayOfMonth.endOf('month').format('YYYY-MM-DD')
}
weeks.push({
text: text,
value: text,
dataType: dataType,
startDate: startDateStr,
endDate: endDateStr
})
startDate = startDate.add(7, 'day')
weekCount++
}
return weeks
}
文本展示
场景:文本超长隐藏,但是移动端没有title,所有添加了一个展示/闭合 按钮

<div class="lsh-chart-legend-wrap ">
<long-text-collapse class="legend-item" v-for="(item,index) in totalData"
:text="item.name"
:value="item.value"
:color="item.color"
:key="index" />
</div>
<style lang="scss" scoped>
.lsh-chart-legend-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
overflow: auto;
/*一行排列2个*/
.legend-item {
width: calc(50% - 5px);
font-family: Source Han Sans CN-Regular, Source Han Sans CN;
margin-bottom: 8px;
&:nth-child(2n) {
margin-left: 10px; /* 指定每行 2个元素的间隔,是每隔2个设置*/
}
}
}
</style>
一行排列3个
.legend-item {
width: calc(33% - 4px);
font-family: Source Han Sans CN-Regular, Source Han Sans CN;
margin-bottom: 8px;
margin-right: 6px; /* 4px*3/2 */
&:nth-child(3n) {
margin-right: 0;
}
}
2列排列的数据,如果容器超出则自动换行
默认 card-item 宽度是 50%,然后遍历 每个 card-item 元素,判断scrollWidth 是否大于offsetWidth,则表示 出现滚动了,这时设置 宽度为 100%
<div class="card long_omit">
<div class="card-item long_omit" :ref="key+'ItemRef'" v-for="(value,key) in detail.extraInfo.data">
<span class="card-item__label">{{key}}:</span>
<span class="card-item__value">{{value}}</span>
</div>
</div>
mounted () {
if(this.detail.extraInfo && this.detail.extraInfo.data){
for (var key in this.detail.extraInfo.data) { // this.detail.extraInfo.data 是一个map对象 ,根据自己实际的数据格式进行遍历
const textElement = this.$refs[key+'ItemRef'][0];
if (textElement.scrollWidth > textElement.offsetWidth) { // 水平方向出现滚动条了
textElement.style.width = '100%';
}
}
}
},
文本添加一个标记点
效果图: 
最优写法,将标记点和文本放到一起通过before,并且通过是否传递color背景色,控制是否显示,可以直接 对父容器定义 before属性,
改写法支持如果color为空,则不显示
<span class="text__content" ref="textRef" @click.stop="toggleText" :class="{'has-tag': color}"
:style="{'--background-color': color}" >{{ text }}</span>
<seyle lang="scss" scoped>
.text__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:before {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
&.has-tag:before{
content: '';
background-color: var(--background-color);
}
}
</seyle>
常规想法是 父容器下添加2个元素
<div class="text-wrap" :class="{'unfold': unfoldFlag}">
<div class="text__tag" :style="{'background-color': dataItem.color}" v-if="dataItem.color"></div>
<span class="text__content" ref="textRef" @click="toggleText">{{ dataItem.name }}</span>
</div>
<seyle lang="scss" scoped>
.text__tag {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
flex-shrink: 0;
}
.text__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</seyle>
移动端对指定区域进行缩放
通过2指对指定区域进行缩放查看,并且松手后容器会还原,类似于放大镜功能
- 在
<template>部分,为表单容器元素添加一个touchstart事件监听器来检测手势开始。
html复制代码<template>
<div class="form-card" v-if="form.content" ref="formContainer" @touchstart="handleTouchStart">
<!-- 需要缩放查看的区域 -->
</div>
</template>
- 在 Vue 组件的方法部分,定义
handleTouchStart方法来处理手势开始事件。
javascript复制代码<script>
export default {
methods: {
handleTouchStart(event) {
if (event.touches.length === 2) {
// 获取双指触摸的起始位置信息
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.startDistance = getDistance(touch1, touch2);
}
},
// 定义一个辅助函数来计算两个触摸点之间的距离
getDistance(touch1, touch2) {
const dx = touch2.pageX - touch1.pageX;
const dy = touch2.pageY - touch1.pageY;
return Math.sqrt(dx * dx + dy * dy);
},
},
};
</script>
- 继续在 Vue 组件中,添加
touchmove和touchend事件的监听器来检测手势的变化和结束。
javascript复制代码<script>
export default {
data() {
return {
startDistance: 0, // 双指触摸的起始距离
currentScale: 1, // 当前缩放比例
};
},
methods: {
handleTouchStart(event) {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.startDistance = this.getDistance(touch1, touch2);
}
},
handleTouchMove(event) {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = this.getDistance(touch1, touch2);
// 根据触摸点之间的距离变化来计算缩放比例
const scale = currentDistance / this.startDistance;
this.currentScale = scale;
// 根据缩放比例设置表单容器元素的样式
this.$refs.formContainer.style.transform = `scale(${scale})`;
}
},
handleTouchEnd() {
// 手势结束时重置缩放比例和表单容器元素的样式
this.currentScale = 1;
this.$refs.formContainer.style.transform = 'scale(1)';
},
getDistance(touch1, touch2) {
const dx = touch2.pageX - touch1.pageX;
const dy = touch2.pageY - touch1.pageY;
return Math.sqrt(dx * dx + dy * dy);
},
},
};
</script>
- 在表单容器元素上添加
touchmove和touchend的事件监听器。
html复制代码<template>
<div class="form-card" v-if="form.content" ref="formContainer"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd">
<!-- 自定义表单 -->
<v-form-render :option-data="optionData" ref="vFormRef" :isViewForm="true"></v-form-render>
</div>
</template>
这样,当用户在移动端使用双指进行缩放手势操作时,自定义表单容器元素会根据触摸点之间的距离变化来实现缩放效果,并不会影响其他区域。
请注意,上述示例只提供了基本的原理和代码结构,具体的实现需要根据你的项目结构和需求进行适当的调整和扩展。
富文本强行修改白色字体为黑色
场景说明:PC是深色背景,默认字体颜色是白色,但是移动端是白色背景导致字体无法正常显示,所有需要强行修改背景颜色
<div>
<quill-editor
v-model:value="fieldModel" v-if="!formConfig.isViewForm"
:options="editorOption"
:disabled="field.options.disabled"
@blur="handleRichEditorBlurEvent"
@focus="handleRichEditorFocusEvent"
@change="handleRichEditorChangeEvent"
:style="!!field.options.contentHeight ? `height: ${field.options.contentHeight};`: ''"></quill-editor>
<!-- 最下面的添加了 after属性,构造一个删除按钮,但是无法定义click事件,如果后期还是需要清除按钮,则可以考虑用一个父节点包裹 vue-editor 富文本-->
<!-- 添加了 预览模式 ,不显示 表单控件-->
<pre v-if="formConfig.isViewForm" class="input-text editor-text" v-html="fieldModel"></pre>
</div>
processFieldModel() {
this.$nextTick(() => {
const preElement = this.$el.querySelector('.input-text.editor-text');
if (preElement) {
const parser = new DOMParser();
const doc = parser.parseFromString(this.fieldModel, 'text/html');
const elements = doc.querySelectorAll('[style*="color: rgb(255, 255, 255)"]');
elements.forEach((element) => {
element.style.color = '#2c3e50'; // 替换为黑色
});
const processedFieldModel = doc.body.innerHTML;
preElement.innerHTML = processedFieldModel;
}
});
}
导出表格
https://blog.csdn.net/m0_51431448/article/details/128630505
handleExport() {
const summaryRow = ['汇总'];
const header = [];
const columns = this.$refs.tableBox1.columns;
for (let column of columns) {
if (column.label) {
header.push(column.label);
} else {
header.push(''); // 如果 label 为空,用空字符串代替
}
}
const exportData = this.list.map((item, index) => {
let itemArr = [index + 1, item.mName, item.sName, item.unitName];
this.columns.forEach((inner_item) => {
itemArr.push(item[inner_item.prop])
});
itemArr.push(item.rowTotal)
return itemArr;
});
exportData.unshift(header);
exportData.unshift(summaryRow);
const XLSX = require('xlsx');
let ws = XLSX.utils.aoa_to_sheet(exportData)
this.setExcelStyle(ws) // 设置样式
// 给 标题行添加 背景色
this.setTileBgColre(header,ws);
ws['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c:16 } } ];
let wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws)
let wbout = XLSXS.write(wb, { bookType: 'xlsx', bookSST: false, type: 'binary' })
try {
FileSaver.saveAs( new Blob([this.s2ab(wbout)], { type: "application/octet-stream" }), "exported_data.xlsx");
} catch(e) {
console.error(e, wbout, '----->>>')
}
}
setTileBgColre(headData,ws){
},
// 设置导出Excel样式 这里主要是关注单元格宽度
setExcelStyle(data) {
let borderAll = {
//单元格外侧框线
top: {
style: "thin",
},
bottom: {
style: "thin",
},
left: {
style: "thin",
},
right: {
style: "thin",
},
}
data['!cols'] = []
for(let key in data) {
if(data[key].constructor === Object) {
data[key].s = {
border: borderAll, // 边框
alignment: {
horizontal: "center", //水平居中对齐
vertical: "center", // 垂直居中
},
font: {
sz: 11,
},
bold: true,
numFmt: 0
}
data["!cols"].push({ wpx: 120 }); // 单元格宽度
}
}
},
自定义组件双向绑定实现
1. vue2的实现
通过
watch来实现
- 父组件
<importTypeSecond v-model="formData.importWay" />
- 子组件
<template>
<div class="dataContent">
<div class="dataItem flex-col clickable" :class="activeId === item.id? 'active' : ''"
v-for="(item, index) in importWay" :key="item.id" @click="handleClick(item)" :style="{ cursor: item.disabled ? 'not-allowed' : 'pointer' }"
>
<div class="dataName">{{ item.name }}</div>
<div class="dataDesc">{{ item.desc }}</div>
</div>
</div>
</template>
<script>
export default {
name: 'importTypeSecond',
props: {
value: {
type: Number,
default: 1
}
},
data() {
return {
importWay: [{
id: 1,
name: '带标注导入',
}, {
id: 2,
name: '不带标注导入',
}],
activeId: this.value
}
},
watch: {
value(newVal) {
this.activeId = newVal;
},
activeId(newVal) {
this.$emit('input', newVal);
},
},
methods: {
handleClick(item) {
this.activeId = item.id
}
}
}
</script>
2. 通过计算属性实现
通过
computed实现
- 父组件
<footer-title v-model="sampleTags"/>
<!-- 其中sampleTags对象数组 -->
- 子组件
<template>
<div class="tagsContainer flex-row">
<div class="tagItem text_not_select" v-for="(item,indexx) in dataList">
<span>{{ item.tagName }}</span>
<i class="el-icon-close" @click="deleteTag(item,indexx)"></i>
</div>
</div>
</template>
<script>
export default {
name: "FooterTitle",
data() {
return {}
},
props:{
value: {
type: Array,
default: () => []
}
},
computed: {
dataList(){
return JSON.parse(JSON.stringify(this.value));
},
},
methods: {
// 删除标签
deleteTag(item,index) {
const newData = [...this.dataList]
newData.splice(index,1);
this.$emit("input", newData);
}
}
}
</script>
3. 复杂一点的双向绑定实现
涉及到父组件,子组件,子子组件
场景描述:物资选择,父组件只有一个标签,其他业务功能都在中间组件中,中间组件有个弹窗组件,弹窗选择后数据及时回显到父组件中

- 父组件
其中父组件中物资是以数组对象的形式传递,这里先将数据id提取出来,以逗号合并成字符串(补充:保存的时候需要数组对象,所以返回到父组件的是对象数组,在子组件中处理好返回给父组件)
<multi-select-wrap v-model="checkMatrIds" :defaultOptions="matrList" title="点击添加物资" :isDelBtn="true" @selectItem="selectItem" />
// 初始化 已选物资ids
initCheckMatrIds(){
if (!this.form.emgBasActvMatrMapList){
this.$set(this.form,'emgBasActvMatrMapList',[]);// 初始化
}
const ids = this.form.emgBasActvMatrMapList.map(item=>item.matrId)||[];
this.checkMatrIds = ids.join(',');
},
因为在子子组件中,也实现了双向绑定,所以无法直接使用父组件传递过来的vlue对象,这里通过复制localValue: this.value 来复制
data() {
return {
popupshow: false,
localValue: this.value, // 本地的 value
}
},
同时 通过watch来实现对数据变化的监听
watch: {
value(newVal) {
this.localValue = newVal; // 当 prop 的值变化时,更新本地的 value
},
localValue(newVal) {
this.$emit('input', newVal); // 当本地的 value 变化时,触发 input 事件通知父组件
this.$emit('selectItem', this.computedOptions); //
}
},
拖拽滑块实现布局
思路:拖拽滑块,改变单个图片/容器的宽度【高度通过设计稿设定的宽高比来计算】然后计算一行布局的最大能排列的数量,当前按理默认排列一行6个

<template>
<!-- 容器 -->
<div ref="imgWrapRef" class="tabs-list-container flex-row" v-loading="loading" :style="getgridTemplateColumns">
<tabsItem ref="tabsItem" class="tab-item" :style="{ width: widthVal1+'px', height: heightVal1+'px' }"
v-for="item in list" :key="item.id" :item="item"/>
</div>
<!-- 滑块-->
<el-slider v-model="value" @input="changeValue" :min="validMin" :max="imgWrapWidth" :step="sliderStep"></el-slider>
</template>
<script>
export default {
data() {
return {
value: 0, // 滑块初始化值
sliderStep: 1,// 滑块步长
widthVal1: 0,
heightVal1: 0,
imgWrapWidth: 0, // 图片预览区总宽度
gridTemplateColumns: 0,
validMin: 0, // 最小值s
list:[]
}
},
computed: {
getgridTemplateColumns() {
return `gridTemplateColumns:repeat(${this.gridTemplateColumns}, 1fr)`
},
},
methods: {
// 点击标签tab,重新计算图片预览区的大小
initImgeWrapSize() {
if (this.isLastLevel) {
this.$nextTick(() => {
this.initWrapperWidth();
})
}
},
/**
* 初始化的时候根据容器的尺寸,计算图片的大小
*/
initWrapperWidth() {
this.imgWrapWidth = this.$refs.imgWrapRef.offsetWidth
this.widthVal1 = this.imgWrapWidth / 6;
this.heightVal1 = this.widthVal1 / 260 * 342;
this.validMin = this.widthVal1;
this.value = this.widthVal1;
this.sliderStep = (this.imgWrapWidth - this.widthVal1) / 100;
},
// 通过滑块,拖拽设置容器大小,实现图片的放大缩小【由初始的 一行10张,变成一行一张】
changeValue(val) {
if (!val) {
return
}
this.widthVal1 = val;
this.heightVal1 = this.widthVal1 / 260 * 342;
// 更新网格列数
this.gridTemplateColumns = this.calculateGridColumns(this.widthVal1);
},
// 计算网格列数的方法
calculateGridColumns(widthVal) {
if (widthVal * 2 > this.imgWrapWidth) return 1; // 2个放不下 故只显示1个
if (widthVal * 3 > this.imgWrapWidth) return 2; // 3个放不下 故只显示2个
if (widthVal * 4 > this.imgWrapWidth) return 3; //....
if (widthVal * 5 > this.imgWrapWidth) return 4;
if (widthVal * 6 > this.imgWrapWidth) return 5;
return 6;
},
}
}
</script>
<style lang="scss" scoped>
.tabs-list-container {
width: 100%;
height: 100%;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(6, 1fr); /* 初始时 6 列 */
gap: 0px;
justify-items: center; /* 每个网格项在各自的单元格内居中 */
.tab-item {
width: 100%;
height: auto;
object-fit: cover;
transition: width 0.4s, height 0.4s; /* 增加平滑过渡效果 */
padding: 5px;
box-sizing: border-box;
}
}
</style>