极限可视化大屏开发探索

819 阅读6分钟

边界

  1. 非纯技术型团队,承接完成市场项目即可挣钱
  2. 有一定量的自定义效果(有UI介入设计)
  3. 有一定量的定制功能,会指定适配的屏幕分辨率
  4. 页面不需要特别酷炫
  5. 项目数量有一定规模,每个项目都存在大量重复开发

方案选择

从市场需求、团队整体开发能力、实际产出效率、稳定性等方面考量,选择哪一种方式完成一个大屏项目花费的时间、人力成本更少,更易落地。

  1. 模板化项目

    优点:完整的初始项目,立马可以部署

    缺点:在模板项目代码基础上修改,适配新的UI图,代码冗余。修改的时间成本可能很高,并且代码质量不高

  2. 可视化拖拽

    优点:运营或非技术人员可以自主生成页面

    缺点:样式过于生硬,想要二次开发难度较大,自研拖拽系统需要成本。使用其他平台的可视化生成系统,价格不菲

  3. 项目预设

    优点:模板化项目的一种,但只保留最小预设和约定,具体业务代码重新开发。

    缺点:几乎每个页面都需要从0开发

经过几个项目实践下来,因为团队在开发大屏方面都是很有经验的小伙伴,重新开发也不慢,代码质量比起修改已有代码会更高,没有过多的冗余代码,并且可以完全适配所有定制内容,所以选择第三种,那么具体就看第三种怎么样能够做到更高效。

具体预设内容

采用VUE技术栈,直接vue-cli(@vue/cli 4.5.12)生成初始项目。

1. 布局

基本布局一般不会变,留出主要功能模块卡槽,包括顶部导航,底部菜单,每个项目按区块定制即可。

各个板块的尺寸根据项目要求调整。

layout.png

App.vue
<template>
    <div id="app">
        <router-view />
    </div>
</template>

<style lang="less">
#app {
    height: 100%;
    color: #2c3e50;
    text-align: center;
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
</style>

layout.vue
<template>
    <div class="page-wrap">
        <div class="head">
            <!-- 顶部导航 -->
            <TopNavigator />
        </div>
        <div class="center-wrap">
            <!-- 中间地图部分 -->
            <CenterMap />
            <!-- 主体内容页面 -->
            <router-view></router-view>
        </div>
        <div class="foot">
            <!-- 底部菜单 -->
            <BottomMenu />
        </div>
    </div>
</template>


<script>
import TopNavigator from './topNavigator'
import CenterMap from './centerMap'
import BottomMenu from './bottomMenu'
export default {
    components: { TopNavigator, CenterMap, BottomMenu }
}
</script>

<style lang="less" scoped>
.page-wrap {
    position: relative;
    height: 100%;
    .head {
        position: fixed;
        z-index: 1;
        height: 1rem;
        width: 100%;
        background-color: fade(#eee, 50%);
    }
    .foot {
        position: fixed;
        bottom: 0;
        left: 50%;
        transform: translateX(-50%);
        width: 19.4rem;
        height: 2.1rem;
        z-index: 0;
        background-color: fade(#eee, 50%);
    }

    .center-wrap {
        position: relative;
        height: 100%;
        overflow-y: auto;
    }
}
</style>
主体内容页
<template>
    <div class="playground">
        <div class="left"></div>
        <div class="center"></div>
        <div class="right"></div>
    </div>
</template>

<script>
export default {}
</script>

<style lang="less" scoped>
.playground {
    position: relative;
    height: 0;
    .left,
    .right {
        position: absolute;
        padding: 0.32rem 0.34rem 0.29rem 0.48rem;
        top: 1rem;
        width: 5rem;
        height: 8rem;
        border: 1px solid #3196fa88;
        border-radius: 0.24rem;
        background-image: linear-gradient(180deg, rgba(49, 150, 250, 0.25), rgba(49, 150, 250, 0));
    }
    .left {
        left: 0.48rem;
        border-left: none;
    }
    .right {
        right: 0.48rem;
        border-right: none;
    }
}
</style>

2. css

@import './variable.less'; // 变量声明
@import './reset.less'; // 重置默认样式
@import './element-ui/index.less'; // 重写element-ui默认样式
@import './common.less'; // 通用样式
@import './tool.less'; // 原子类
@import './animation.less'; // 动画类
common.less
html {
    font-size: 100px;
}
// 内容板块背景图
.bg1 {
    background: url('~@/assets/img/bg1.png') no-repeat top left;
    background-size: 100% 100%;
}

bg1.png

variable.less

这里需要跟UI协商好切图的风格,颜色、字体等,达成一致,完成定义。

@baseUnit: 0.04rem; // 最小单位
@defaultFontSize: 0.14rem; // 默认字体大小

// color
@primary: #3196fa;
@border: #e8e8e8;
@blue1: #1890ff;
@blue2: #b0d0ee;
@blue3: #99d0f2;
@blue4: #5bebff;
@red: #ff4d4f;
@red1: #f56c6c;
@yellow: #facc14;
@yellow1: #ffae00;
@gray: rgba(0, 0, 0, 0.45);

tool.less

其中包括margin、padding、字体、颜色、width、line-height、border、布局等原子样式。

// 生成padding、margin工具类
// 比如 .mt1 = "margin-top: 0.04rem"、mb2 = "margin-bottom: 0.08rem"
.loopPaddingMargin(@counter) when (@counter > 0) {
    .p@{counter} {
        padding: (@baseUnit * @counter);
    }
    .pt@{counter} {
        padding-top: (@baseUnit * @counter);
    }
    .pr@{counter} {
        padding-right: (@baseUnit * @counter);
    }
    .pb@{counter} {
        padding-bottom: (@baseUnit * @counter);
    }
    .pl@{counter} {
        padding-left: (@baseUnit * @counter);
    }
    .m@{counter} {
        margin: (@baseUnit * @counter);
    }
    .mt@{counter} {
        margin-top: (@baseUnit * @counter);
    }
    .mr@{counter} {
        margin-right: (@baseUnit * @counter);
    }
    .mb@{counter} {
        margin-bottom: (@baseUnit * @counter);
    }
    .ml@{counter} {
        margin-left: (@baseUnit * @counter);
    }
    .loopPaddingMargin((@counter - 1)); // 递归调用
}
.loopPaddingMargin(20);

// 生成fontSize工具类,比如 .f14
.loogFontSize(@counter) when (@counter > 10) {
    .f@{counter} {
        font-size: (0.01rem * @counter);
    }
    .loogFontSize((@counter - 1));
}
.loogFontSize(60);
.b { font-weight: bold; } .n { font-weight: normal; }

// 字体颜色, 背景色(less >= 3.7)
@colorList: {
    primary: @primary;
    blue1: @blue1;
    blue2: @blue2;
    blue3: @blue3;
    blue4: @blue4;
    red: @red;
    red1: @red1;
    yellow: @yellow;
    yellow1: @yellow1;
    gray: @gray;
};
each(@colorList, {
    .bg-@{key} {
      background: @value !important;
    }
    .c-@{key} {
      color: @value !important;
    }
});

// 生成width宽度工具类,比如 .w200
.loogWidth(@counter) when (@counter > 0) {
    .w@{counter} {
        width: (0.01rem * @counter) !important;
    }
    .loogWidth((@counter - 4));
}
.loogWidth(600);
.pct100 {
    width: 100% !important;
}

// line-height
.loogLineHight(@counter) when (@counter > 0) {
    .lh@{counter} {
        line-height: (0.01rem * @counter) !important;
    }
    .loogLineHight((@counter - 1));
}
.loogLineHight(40);

// border
.bdr {
    border: 1px solid @border;
}
.bdr-l {
    border-left: 1px solid @border;
}
.bdr-r {
    border-right: 1px solid @border;
}
.bdr-t {
    border-top: 1px solid @border;
}
.bdr-b {
    border-bottom: 1px solid @border;
}

// 布局
.tl {
    text-align: left;
}
.tr {
    text-align: right;
}
.tc {
    text-align: center;
}
.dib {
    display: inline-block;
}
.flex-box {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

// other
.finger {
    cursor: pointer;
}
.ell {
    display: inline-block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.ell-2row {
    text-overflow: -o-ellipsis-lastline;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

animation.less
.fade-in {
    .setKeyframe(fadeIn);
}
.fade-in-up {
    .setKeyframe(fadeInUp);
}

.setKeyframe(@keyframeName) {
    animation-name: @keyframeName;
    animation-duration: 1s;
    animation-iteration-count: 1;
    animation-delay: 0s;
    animation-fill-mode: 'both';
}

@keyframes fadeIn {
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}

@keyframes fadeInUp {
    from {
        opacity: 0;
        margin-top: 0.3rem;
    }
    to {
        opacity: 1;
        margin-top: 0;
    }
}

element-ui/index.less
.ele-table1 {
    background: transparent;
    border-bottom: none;
    &.el-table {
        /deep/ table {
            border-collapse: separate;
            border-spacing: 0px 0.08rem;
            // 表头不需要border-spacing
            &.el-table__header {
                border-spacing: 0;
            }
            tr {
                background: transparent;
                &:hover {
                    td {
                        background: #2debe033;
                    }
                }
                &.el-table__row {
                    background: #2debe011;
                }
                th {
                    color: #fff;
                    font-size: 0.14rem;
                    font-weight: bold;
                    background: transparent;
                    padding: 0.08rem 0 0 0;
                    border: none;
                }
                td {
                    color: #ecf5ff;
                    background: #2debe011;
                    padding: 0.08rem 0;
                    font-size: 0.14rem;
                    border: none;
                }
            }
            .cell {
                line-height: 0.14rem;
            }
        }
    }
    // 底下始终有根线,去掉
    &--border::after,
    &--group::after,
    &::before {
        display: none;
    }
}

table1.png

3. echarts图表

为什么没有直接做组件,因为每个项目对图表的定制性其实很强,组件复用率不高。所以同样采取做一些基本预设的方式(通过Vue.extend方法混人)。

mixins
import { deepMerge } from '@/utils'
const defaultFontSize = 16

// 通用配置
const commonOptions = {
    color: ['#fafb02', '#2debe0', '#2390ff', '#ff9936', '#b65fff'],
    textStyle: {
        color: '#fff',
        fontSize: defaultFontSize,
    },
    grid: {
        left: '10%',
        right: '0%',
        top: '5%',
        bottom: '8%',
    },
    legend: {
        textStyle: {
            color: '#fff',
        },
    },
    xAxis: {
        axisLabel: {
            color: '#eee',
            fontSize: defaultFontSize,
        },
        axisLine: {
            lineStyle: {
                color: '#fff',
            },
        },
    },
    yAxis: {
        axisLabel: {
            color: '#eee',
            fontSize: defaultFontSize,
        },
    },
}

export default {
    data() {
        return {}
    },
    methods: {
        // 混入通用配置的函数
        mixinEcharts(options) {
            return deepMerge(commonOptions, options)
        },
    },
    updated() {},
}
main.js
import EchartsMixin from '@/mixins/echarts'
Vue.mixin(EchartsMixin)
new Vue({
    router,
    store,
    render: (h) => h(App),
}).$mount('#app')
bar.vue

在组件中调用this.mixinEcharts(options)方法,混入通用配置

mixinOption() {
    const barSeries = {
        type: 'bar',
        itemStyle = {
            borderRadius: [15, 15, 0, 0]
        }
    }
    const options = {
        legend: { show: true },
        dataset: {
            dimensions: ['product', '2015', '2016', '2017', '2018', '2019'],
            source: [
                { product: '棉花', '2015': 43.3, '2016': 85.8, '2017': 93.7, '2018': 93.7, '2019': 93.7 },
                { product: '玉米', '2015': 83.1, '2016': 73.4, '2017': 55.1, '2018': 63.7, '2019': 73.7 },
                { product: '稻谷', '2015': 86.4, '2016': 65.2, '2017': 82.5, '2018': 93.7, '2019': 23.7 },
                { product: '小麦', '2015': 72.4, '2016': 53.9, '2017': 39.1, '2018': 13.7, '2019': 83.7 },
                { product: '高粱', '2015': 72.4, '2016': 53.9, '2017': 39.1, '2018': 13.7, '2019': 83.7 }
                ]
            },
        xAxis: {
            type: 'category',
        },
        series: [
            barSeries,
            barSeries,
            barSeries,
            barSeries,
            barSeries,
            { type: 'line', color: ['#eee'] }
        ]
    }
    // 这里混入通用配置!!!
    return this.mixinEcharts(options)
}

bar1.png

4. utils

function isObject(obj) {
    return Object.prototype.toString.call(obj) === '[object Object]'
}
function isArray(arr) {
    return Array.isArray(arr)
}
// 合并两个对象
export const deepMerge = (target, ...arg) => {
    return arg.reduce((old, cur) => {
        return Object.keys(cur).reduce((subAttr, key) => {
            const srcVal = cur[key]
            // 如果相同属性值的 基本数据类型冲突并且后者是object类型,直接取后者的值
            // 因为这种情况会报错,比如 { a1: 1 } 和 { a1: {x: 1, y: 2 } }
            if (typeof subAttr[key] !== typeof srcVal[key] && typeof srcVal[key] === 'object') {
                subAttr[key] = srcVal[key]
            }

            if (isObject(srcVal)) {
                subAttr[key] = deepMerge(subAttr[key] ? subAttr[key] : {}, srcVal)
            } else if (isArray(srcVal)) {
                subAttr[key] = srcVal.map((item, idx) => {
                    if (isObject(item)) {
                        const curAccVal = subAttr[key] ? subAttr[key] : []
                        return deepMerge(curAccVal[idx] ? curAccVal[idx] : {}, item)
                    } else {
                        return item
                    }
                })
            } else {
                subAttr[key] = srcVal
            }
            return subAttr
        }, old)
    }, target)
}

// 根据适配屏幕分辨率情况转换px值
export const parsePx = (src) => {
    ...
} 

demo.png

5. 后续 todo

地图接口封装,不需要关心地图厂商底层实现

cli直接生成项目初始模板

浙江大华技术股份有限公司-软研-智慧城市产品研发部招聘高级前端,欢迎来撩,有意向可发送简历到chen_zhen@dahuatech.com,长期有效