【广告投放系统】头条可视化投放平台vue3+element-plus+vite落地历程和心得体会
前言
hallo,又是许久未见,昨天也是正式把公司内部的广告投放平台暂时落地,我也即将离开待了两年多的地方。言归正传,由于头条广告后台的升级改版,因此为了满足内部投放需求,做了一个可视化的投放平台,时间大概是1个星期多,然后我会阐述我是如何在最短的时间内完成这个平台的。
需求梳理
-
时间尽可能的短
为了实现这一点,可能暂时不考虑非常华丽的UI设计和变化,对于后台系统来说,快速上手和简单易用是最重要的 -
技术选型
实际上在头条后台没有改版之前,公司内部使用的是React18那一套玩法,我也亲身参与和维护过,那么之所以选择vue3。
一方面是:vue3发展到现在跟React的差距并不是特别大,特别在复杂的计算场景上面。
二方面是:兼顾公司内部前端的技术栈考虑,公司主要以vue为主,那么为了之后的维护和更新,vue技术栈最合适不过
三方面是:旧版广告系统迭代了非常多版本,既不满足新版需求,其次是数据维护极其困难,数据污染严重,规范和可靠性不强,因此二次开发比重新创作难度更大。 -
调试简单
-
易于维护
设计思路
借鉴于上一代广告投放系统的痛点,其实最难的就是数据的回显和数据的汇总,导致出现的各种问题。因此我给自己提出以下几点原则:
-
不违背单向数据源原则
数据最终的处理,应该由顶层决定,数据可以获取,但是修改必须经过顶层同意。
-
数据驱动
为了解决回显问题,实际上迭代到后期,组件和组件之间嵌套的层级可能会比较深,那么就需要一个公共数据管理库去统一管理数据,所有的数据在初始化的时候,可以通过接口获取数据之后,存储到数据管理库去统一处理,再在各个组件中做监听,数据变化驱动视图变化。
方案选型
- 公共状态管理库(pinia)官方支持,社区活跃
- 请求库(axios),请求响应拦截,二次封装,自定义参数
- 打包构建工具(vite),本地代理,插件丰富,官方支持
- 组件库(element-plus),支持企业级系统搭建,适配vue3
- 路由管理(vue-router)
- 调试工具(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()
这样有几个好处:
- 组件和组件之前是解耦的。
不会说一个组件影响到了其他组件,组件的事情组件自己处理,我要的只是处理完之后的数据,在多人开发中,可以把页面组件化,这一块组件只做自己的事情,最终要的只是通过你暴露出来的方法拿到最终处理好的数据即可。 - 单一数据源原则
地域数据树级处理
从接口拿到中国地域数据的时候,首先要考虑以下问题:
- 数据量大
地域数据通常数据量都会比较大,反复的调用接口去拿显然是不合适的,因为地域数据通常是不会改变的,因此在首次调用接口成功拿到数据之后,就应该存储本地了,之后都看本地有就本地拿,没有就调用接口,接口需要更新再重新调用重新执行流程 - 怎么存
对于浏览器来说,存储的方式有很多种:
例如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去实现简易版的广告投放系统中遇到的问题和解决思路,希望能给到一些设计思路,同时如果有更好的建议,欢迎提出!