当前位置: 首页 > article >正文

【广告投放系统】头条可视化投放平台vue3+element-plus+vite落地历程和心得体会

前言

hallo,又是许久未见,昨天也是正式把公司内部的广告投放平台暂时落地,我也即将离开待了两年多的地方。言归正传,由于头条广告后台的升级改版,因此为了满足内部投放需求,做了一个可视化的投放平台,时间大概是1个星期多,然后我会阐述我是如何在最短的时间内完成这个平台的。

需求梳理

  • 时间尽可能的短
    为了实现这一点,可能暂时不考虑非常华丽的UI设计和变化,对于后台系统来说,快速上手和简单易用是最重要的

  • 技术选型
    实际上在头条后台没有改版之前,公司内部使用的是React18那一套玩法,我也亲身参与和维护过,那么之所以选择vue3。
    一方面是:vue3发展到现在跟React的差距并不是特别大,特别在复杂的计算场景上面。
    二方面是:兼顾公司内部前端的技术栈考虑,公司主要以vue为主,那么为了之后的维护和更新,vue技术栈最合适不过
    三方面是:旧版广告系统迭代了非常多版本,既不满足新版需求,其次是数据维护极其困难,数据污染严重,规范和可靠性不强,因此二次开发比重新创作难度更大。

  • 调试简单

  • 易于维护

设计思路

借鉴于上一代广告投放系统的痛点,其实最难的就是数据的回显和数据的汇总,导致出现的各种问题。因此我给自己提出以下几点原则:

  • 不违背单向数据源原则
    数据最终的处理,应该由顶层决定,数据可以获取,但是修改必须经过顶层同意。
    在这里插入图片描述

  • 数据驱动
    为了解决回显问题,实际上迭代到后期,组件和组件之间嵌套的层级可能会比较深,那么就需要一个公共数据管理库去统一管理数据,所有的数据在初始化的时候,可以通过接口获取数据之后,存储到数据管理库去统一处理,再在各个组件中做监听,数据变化驱动视图变化。

在这里插入图片描述

方案选型

  1. 公共状态管理库(pinia)官方支持,社区活跃
  2. 请求库(axios),请求响应拦截,二次封装,自定义参数
  3. 打包构建工具(vite),本地代理,插件丰富,官方支持
  4. 组件库(element-plus),支持企业级系统搭建,适配vue3
  5. 路由管理(vue-router)
  6. 调试工具(code-inspector-plugin)支持多种前端技术框架和构建工具,ide支持,当组件嵌套深层次的时候,能通过快捷键快速定位代码位置

项目搭建

那么初次创建项目的时候,所有的资源都会在src下面去创作,结构大致如下,我也借鉴了在Flutter端和在React端的经验。
在这里插入图片描述

目录解析

  • 所有的请求都在api目录下面,不同的请求api类型区分不同的文件,比如账户相关单独一个文件,资源包相关单独一个文件
  • 静态资源都在assets下面
  • 所有的组件都放在components下面,不同的组件类型单独一个文件夹命名,子组件也是如此
  • 路由文件放在router下面,统一由index.js去对外暴露
  • 公共管理库放在store下面,统一由index.js去对外暴露
  • 所有的工具类放在utils,不同种类的工具单独文件夹分开处理
  • 主视图文件放在views下面,充当为顶层决策者
  • App.vue文件为最高决策者,如果未来规划页面布局应当在此规划

本地代理

vite本身就支持本地代理操作,所有的配置都在vite.config.js文件中去配置,假如是公司内部使用,那么公司内部系统会在请求上带上特殊参数,那么可以通过代理的手段去设置。

 server: {
    // port: 8080, // 设置端口号
    // host: '192.168.11.5',
    proxy: {
      '/static': {
        target: 'http://xxxxxx.com',
        ws: false,
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/static/, ''),
        configure: (proxy) => {
          proxy.on('proxyReq', function (proxyReq) {
            proxyReq.setHeader('Cookie', 'my_dev=a221cc9410bae508aa3c1106af467db5; ly_my_user=Ikft5xHzizNCSKdfddTxTVyj9wZ3pdhh; ly_my_refer=1380.8.74.2; PHPSESSID=fufvppacre3nor48aos834dtei; SERVERID=6c7b09313256c7aae1d2b6b27bf60e38|1732675765|1732675765');
          });
        }
      },

请求配置

本地代理是需要和请求配置相对应的,当处理本地环境时,请求链接为配置好的代理头,触发本地代理,完成本地请求。

const service = axios.create({
    timeout: 5000,
    timeoutRetry: true,
    // 请求头信息
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
    },
    // 携带凭证
    withCredentials: true,
    // 返回数据类型
    // responseType: 'json'
})

service.interceptors.request.use(
    config => {
        // 根据custom参数中配置的是否需要baseURL,添加对应的请求头
        if (config?.custom?.baseUrlType) {
            if (process.env.NODE_ENV === 'development') {
                config.baseURL = `/${config.custom.baseUrlType}`
            } else {
                config.baseURL = `//${config.custom.baseUrlType}.${window.location.host.split('.')[1]}.com`
            }
        }

我在请求拦截中就是这么做的。

打包配置

打包注意要在vite.config.js中进行配置,否则可能出现找不到资源的问题

  base: process.env.NODE_ENV === 'production' ? './' : '/',

资源访问配置

这里我为了方便资源的获取,将src目录设置别名

  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },

难点和解析

数据传递

前面说过了,我希望做到的就是不违背单一数据源的原则,因此在数据的处理上,我希望最终都交给顶层去决策,那么是这么处理的:

对于子级组件,我在父级中给予一个ref,为了能够操作子组件

  <CustomTemplate name="选择账户">
            <Account ref="AccountRef" />
   </CustomTemplate>

const AccountRef = ref();

子组件对外暴露一个handleSubmit方法,用于对外抛出数据

const account = await AccountRef.value.handleSubmit() 

这样有几个好处:

  1. 组件和组件之前是解耦的。
    不会说一个组件影响到了其他组件,组件的事情组件自己处理,我要的只是处理完之后的数据,在多人开发中,可以把页面组件化,这一块组件只做自己的事情,最终要的只是通过你暴露出来的方法拿到最终处理好的数据即可。
  2. 单一数据源原则

地域数据树级处理

从接口拿到中国地域数据的时候,首先要考虑以下问题:

  • 数据量大
    地域数据通常数据量都会比较大,反复的调用接口去拿显然是不合适的,因为地域数据通常是不会改变的,因此在首次调用接口成功拿到数据之后,就应该存储本地了,之后都看本地有就本地拿,没有就调用接口,接口需要更新再重新调用重新执行流程
  • 怎么存
    对于浏览器来说,存储的方式有很多种:
    例如cookie、LocalStorage、SessionStorage、浏览器数据库。
    但是我们的数据量比较大,可能需要反复读写,那么就要求:
    一是能够存储比较大的数据
    二是读写得快,不能阻塞渲染进程
    那么数据库IndexedDB就非常合适了,存储容量大,异步操作不阻塞线程,数据结构也足够灵活
  • 怎么展示
    展示当然就选中了element-plus的树级选择器了,但是需要注意的是,由于地域在嵌套方面比较深的时候,渲染会慢,因此我们需要做懒加载(render-after-expand),并且选中的时候,不能选中父子级联(check-strictly),否则严重阻塞渲染。
  <el-tree-select v-model="ACForm.city" :data="cityOptions" multiple :render-after-expand="true"
                        show-checkbox style="width: 240px" placeholder="请选择区域" node-key="id" check-strictly />

并且大概率从头条获取的数据并不是一个标准的树级结构,因此需要修改为标准的树级结构

const addAdminSelectorsCity = (data) => {
    let arr = [];
    data.map(item => {
        let newData = {};
        //这一块根据选用的组件来
        newData.label = item.name; 
        newData.id = item.geoname_id;
        newData.value = item.code;
        if (
            item.name === "台湾省" ||
            item.name === "香港特别行政区" ||
            item.name === "澳门特别行政区"
        ) {
            newData.value = item.geoname_id;
        }
        if (item.sub_districts) {
            newData.children = addAdminSelectorsCity(item.sub_districts);
            if (newData.label === newData.children[0].label) {
                newData = newData.children[0];
            }
        }
        return arr.push(newData);
    }
    );
    return arr;
};

可动态增删数据的数据校验

本身其实el-form 配合el-form-item是有一套标准的校验规范的,在非动态的数据校验不会有任何问题。

 <el-form :model="AdBudgetForm" :rules="rules" style="max-width: 600px" label-width="auto" label-position="left"
            size="default" ref="AdBudgetRef">
            <div class="form-container">
                <el-form-item label="广告预算" prop="budget">
                    <el-input v-model="AdBudgetForm.budget" style="width: 240px" placeholder="请输入广告预算">
                        <template #suffix>元</template>
                    </el-input>
                </el-form-item>
            </div>
        </el-form>

表单定义一个AdBudgetForm负责获取所有数据,定义一个rules负责处理规则,每一项都使用el-form-item进行包裹,通过设置prop去匹配rules中的校验,去完成校验。

但是有一种场景:
在这里插入图片描述
点击能够不断添加,删除又可以批量删除和添加,当条数比较多的时候,肯定不能去手动定义非常多的rules去处理,因为他们实际上校验规则都是同一个。

只需要这么处理即可:

            <div style="margin-bottom: 10px;" v-for="(item, index) in textSourceForm.textSourceList" :key="index">
                <el-form-item :prop="'textSourceList.' + index + '.title'" :rules="rules.title">



const rules = reactive({
    title: [
        {
            validator: textRules,
            trigger: 'blur'
        }
    ]
});

头条自定义日期选择组件

这个组件是在这一位开源的基础上进行二次改造成vue3版本的,完整实例如下,开箱即用:
开源作者地址:https://github.com/xiejunping/andt-components
在这里插入图片描述

        <TimeRangePicker ref="TimeRangePickerRef" :value="mult_timeRange" :data="weektimeDataArr"
                        @clearSelection="clearSelection" />

const weektimeDataArr = ref(weektimeData)
const mult_timeRange = computed(() => {  //时间范围
    return weektimeDataArr.value.map((item) => {
        return {
            id: item.row,
            week: item.value,
            value: splicing(item.child),
        };
    });
});


const clearSelection = () => {
    weektimeDataArr.value.forEach((item) => {
        item.child.forEach((t) => {
            t.check = false; // 直接修改属性即可
        });
    });
}

weektimeData

const formatDate = (date, fmt) => {
    const o = {
        'M+': date.getMonth() + 1,
        'd+': date.getDate(),
        'h+': date.getHours(),
        'm+': date.getMinutes(),
        's+': date.getSeconds(),
        'q+': Math.floor((date.getMonth() + 3) / 3),
        S: date.getMilliseconds()
    }
    if (/(y+)/.test(fmt)) {
        fmt = fmt.replace(
            RegExp.$1,
            (date.getFullYear() + '').substr(4 - RegExp.$1.length)
        )
    }
    for (const k in o) {
        if (new RegExp('(' + k + ')').test(fmt)) {
            fmt = fmt.replace(
                RegExp.$1,
                RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)
            )
        }
    }
    return fmt
}

const createArr = (len) => {
    return Array.from(Array(len)).map((ret, id) => id)
}

const formatWeektime = (col) => {
    const timestamp = 1542384000000 // '2018-11-17 00:00:00'
    const beginstamp = timestamp + col * 1800000 // col * 30 * 60 * 1000
    const endstamp = beginstamp + 1800000

    const begin = formatDate(new Date(beginstamp), 'hh:mm')
    const end = formatDate(new Date(endstamp), 'hh:mm')
    return `${begin}~${end}`
}

const data = [
    '星期一',
    '星期二',
    '星期三',
    '星期四',
    '星期五',
    '星期六',
    '星期日'
].map((ret, index) => {
    const children = (ret, row, max) => {
        return createArr(max).map((t, col) => {
            return {
                week: ret,
                value: formatWeektime(col),
                begin: formatWeektime(col).split('~')[0],
                end: formatWeektime(col).split('~')[1],
                row,
                col
            }
        })
    }
    return {
        value: ret,
        row: index,
        child: children(ret, index, 48)
    }
})

export default data
<template>
    <div class="c-weektime">
        <div class="c-schedue"></div>
        <div :class="{ 'c-schedue': true, 'c-schedue-notransi': mode }" :style="styleValue"></div>

        <table :class="{ 'c-min-table': colspan < 2 }" class="c-weektime-table">
            <thead class="c-weektime-head">
                <tr>
                    <th rowspan="8" class="week-td">星期/时间</th>
                    <th :colspan="12 * colspan">00:00 - 12:00</th>
                    <th :colspan="12 * colspan">12:00 - 24:00</th>
                </tr>
                <tr>
                    <td v-for="t in theadArr" :key="t" :colspan="colspan">{{ t }}</td>
                </tr>
            </thead>
            <tbody class="c-weektime-body">
                <tr v-for="t in data" :key="t.row">
                    <td>{{ t.value }}</td>
                    <td v-for="n in t.child" :key="`${n.row}-${n.col}`" :data-week="n.row" :data-time="n.col"
                        :class="selectClasses(n)" @mouseenter="cellEnter(n)" @mousedown="cellDown(n)"
                        @mouseup="cellUp(n)" class="weektime-atom-item"></td>
                </tr>
                <tr>
                    <td colspan="49" class="c-weektime-preview">
                        <div class="g-clearfix c-weektime-con">
                            <span class="g-pull-left">{{
            selectState ? '已选择时间段' : '可拖动鼠标选择时间段'
        }}</span>
                            <a @click.prevent="clearSelection" class="g-pull-right">清空选择</a>
                        </div>
                        <div v-if="selectState" class="c-weektime-time">
                            <div v-for="t in selectValue" :key="t.id">
                                <p v-if="t.value">
                                    <span class="g-tip-text">{{ t.week }}</span>
                                    <span>{{ t.value }}</span>
                                </p>
                            </div>
                        </div>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</template>

<script setup>
import { computed, defineEmits, defineExpose, defineProps, ref } from "vue";


const emit = defineEmits(['clearSelection']);

// Props
const props = defineProps({
    value: { type: Array, required: true },
    data: { type: Array, required: true },
    colspan: { type: Number, default: 2 },
});

// Reactive State
const width = ref(0);
const height = ref(0);
const left = ref(0);
const top = ref(0);
const mode = ref(0);
const row = ref(0);
const col = ref(0);
const theadArr = ref([]);

// Helper Function
const createArr = (len) => Array.from({ length: len }, (_, id) => id);

// Computed Properties
const styleValue = computed(() => ({
    width: `${width.value}px`,
    height: `${height.value}px`,
    left: `${left.value}px`,
    top: `${top.value}px`,
}));

const selectValue = computed(() => props.value);

const selectState = computed(() => props.value.some((ret) => ret.value));

const selectClasses = (n) => (n.check ? "ui-selected" : "");

// Initialization (created hook)
theadArr.value = createArr(24);

// Methods
const cellEnter = (item) => {
    const ele = document.querySelector(
        `td[data-week='${item.row}'][data-time='${item.col}']`
    );
    if (!ele) return;

    if (!mode.value) {
        left.value = ele.offsetLeft;
        top.value = ele.offsetTop;
    } else if (item.col <= col.value && item.row <= row.value) {
        width.value = (col.value - item.col + 1) * ele.offsetWidth;
        height.value = (row.value - item.row + 1) * ele.offsetHeight;
        left.value = ele.offsetLeft;
        top.value = ele.offsetTop;
    } else if (item.col >= col.value && item.row >= row.value) {
        width.value = (item.col - col.value + 1) * ele.offsetWidth;
        height.value = (item.row - row.value + 1) * ele.offsetHeight;
        if (item.col > col.value && item.row === row.value) top.value = ele.offsetTop;
        if (item.col === col.value && item.row > row.value) left.value = ele.offsetLeft;
    } else if (item.col > col.value && item.row < row.value) {
        width.value = (item.col - col.value + 1) * ele.offsetWidth;
        height.value = (row.value - item.row + 1) * ele.offsetHeight;
        top.value = ele.offsetTop;
    } else if (item.col < col.value && item.row > row.value) {
        width.value = (col.value - item.col + 1) * ele.offsetWidth;
        height.value = (item.row - row.value + 1) * ele.offsetHeight;
        left.value = ele.offsetLeft;
    }
};

const cellDown = (item) => {
    const ele = document.querySelector(
        `td[data-week='${item.row}'][data-time='${item.col}']`
    );
    if (!ele) return;

    mode.value = 1;
    width.value = ele.offsetWidth;
    height.value = ele.offsetHeight;
    row.value = item.row;
    col.value = item.col;
};

const cellUp = (item) => {
    if (item.col <= col.value && item.row <= row.value) {
        selectWeek([item.row, row.value], [item.col, col.value], !item.check);
    } else if (item.col >= col.value && item.row >= row.value) {
        selectWeek([row.value, item.row], [col.value, item.col], !item.check);
    } else if (item.col > col.value && item.row < row.value) {
        selectWeek([item.row, row.value], [col.value, item.col], !item.check);
    } else if (item.col < col.value && item.row > row.value) {
        selectWeek([row.value, item.row], [item.col, col.value], !item.check);
    }

    width.value = 0;
    height.value = 0;
    mode.value = 0;
};

const selectWeek = (rowRange, colRange, check) => {
    const [minRow, maxRow] = rowRange;
    const [minCol, maxCol] = colRange;

    props.data.forEach((item) => {
        item.child.forEach((t) => {
            if (
                t.row >= minRow &&
                t.row <= maxRow &&
                t.col >= minCol &&
                t.col <= maxCol
            ) {
                t.check = check;
            }
        });
    });
};

const clearSelection = () => {
    emit('clearSelection')
};


defineExpose({
    selectValue
})
</script>

<style lang="scss" scoped>
.c-weektime {
    min-width: 640px;
    position: relative;
    display: inline-block;
}

.c-schedue {
    background: #598fe6;
    position: absolute;
    width: 0;
    height: 0;
    opacity: 0.6;
    pointer-events: none;

    &-notransi {
        transition: width 0.12s ease, height 0.12s ease, top 0.12s ease,
            left 0.12s ease;
    }
}

.c-weektime-table {
    border-collapse: collapse;

    th {
        vertical-align: inherit;
        font-weight: bold;
    }

    tr {
        height: 30px;
    }

    tr,
    td,
    th {
        user-select: none;
        border: 1px solid #dee4f5;
        text-align: center;

        line-height: 1.0em;
        transition: background 0.2s ease;
    }

    .c-weektime-head {
        font-size: 12px;

        .week-td {
            width: 9.2em !important;
        }
    }

    .c-weektime-body {
        font-size: 12px;

        td {
            &.weektime-atom-item {
                user-select: unset;
                background-color: #f5f5f5;
                width: 1.4em;
            }

            &.ui-selected {
                background-color: #598fe6;
            }
        }
    }

    .c-weektime-preview {
        line-height: 2.4em;
        padding: 0 10px;
        font-size: 14px;

        .c-weektime-con {
            line-height: 46px;
            user-select: none;
        }

        .c-weektime-time {
            text-align: left;
            line-height: 2.4em;

            p {
                max-width: 625px;
                line-height: 1.4em;
                word-break: break-all;
                margin-bottom: 8px;
            }
        }
    }
}

.c-min-table {

    tr,
    td,
    th {
        min-width: 24px;
    }
}

.g-clearfix {

    &:after,
    &:before {
        clear: both;
        content: ' ';
        display: table;
    }
}

.g-pull-left {
    float: left;
}

.g-pull-right {
    float: right;
}

.g-tip-text {
    color: #999;
}
</style>

性能优化和迭代

  • 在点击不同操作的时候,注意浏览器的性能指标,及时发现究竟哪些操作会阻塞到渲染进程
  • 目前仅是单账户和单广告,实际上做成多账户和多广告只需要补充一些匹配规则,然后做不同的分配即可。

总结

本文简单阐述使用vue3去实现简易版的广告投放系统中遇到的问题和解决思路,希望能给到一些设计思路,同时如果有更好的建议,欢迎提出!


http://www.kler.cn/a/416422.html

相关文章:

  • 初识Hive
  • 41 基于单片机的小车行走加温湿度检测系统
  • spring +fastjson 的 rce
  • 【赵渝强老师】PostgreSQL的数据库
  • 线段树与树状数组 (C++)
  • OpenAI浅聊爬虫
  • # issue 7 TCP回声服务器和客户端
  • RPA:电商订单处理自动化
  • Rust format失败
  • 在Java中使用Apache POI导入导出Excel(二)
  • Milvus 2.5:全文检索上线,标量过滤提速,易用性再突破!
  • JS-对象-DOM-案例
  • request和websocket
  • python自动化测开面试题汇总(持续更新)
  • 【SpringBoot问题】IDEA中用Service窗口展示所有服务及端口的办法
  • 民宿住宿管理系统|Java|SSM|JSP| 前后端分离
  • 使用zabbix监控k8s
  • C#读取本地图像的方法总结
  • 大米中的虫子检测-检测储藏的大米中是否有虫子 支持YOLO,VOC,COCO格式标注,4070张图片的数据集
  • 爬虫获取的数据如何有效分析以支持商业决策?