【广告投放系统】头条可视化投放平台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文件中去配置,假如是公司内部使用,那么公司内部系统会在请求上带上特殊参数,那么可以通过代理的手段去设置。

bash 复制代码
 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');
          });
        }
      },

请求配置

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

bash 复制代码
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中进行配置,否则可能出现找不到资源的问题

bash 复制代码
  base: process.env.NODE_ENV === 'production' ? './' : '/',

资源访问配置

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

bash 复制代码
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },

难点和解析

数据传递

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

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

bash 复制代码
  <CustomTemplate name="选择账户">
            <Account ref="AccountRef" />
   </CustomTemplate>

const AccountRef = ref();

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

bash 复制代码
const account = await AccountRef.value.handleSubmit() 

这样有几个好处:

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

地域数据树级处理

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

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

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

bash 复制代码
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是有一套标准的校验规范的,在非动态的数据校验不会有任何问题。

bash 复制代码
 <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去处理,因为他们实际上校验规则都是同一个。

只需要这么处理即可:

bash 复制代码
            <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

bash 复制代码
        <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

bash 复制代码
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
bash 复制代码
<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去实现简易版的广告投放系统中遇到的问题和解决思路,希望能给到一些设计思路,同时如果有更好的建议,欢迎提出!

相关推荐
卸任5 小时前
Vibe coding工具使用心得
前端·ai编程
brien96 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M6 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写6 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李6 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易7 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易7 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员