项目定位
xx运营数据看板是一个面向企业内部运营、管理层和业务监控场景的数据大屏项目,部署在企业微信工作台入口中。系统聚合砂石骨料价格指数、运价指数、年度交易额、平台交易商品、市场交易动态、AI 控货设备、AI 调度分析和调度动态等业务数据,通过高密度可视化、自动刷新、轮播展示和数字动画,支撑运营侧对交易、运力、货物、设备和调度能力的实时感知。
技术栈
以 Vue 3、Vite、TypeScript、Pinia、Vue Router、ECharts、ECharts GL、Swiper、企业微信 JSSDK、autofit.js、NumberFlow 为主。
技术架构概览
- 前端框架:Vue 3 + Composition API +
<script setup lang="ts">。 - 工程化:Vite 多环境构建,开发环境代理 /api,生产环境按 .env.production 指向正式 API。
- 状态管理:Pinia 管理全局刷新任务和真假数据开关。
- 图表能力:ECharts 折线、散点、面积图,ECharts GL 参数曲面实现 3D 饼图。
- 大屏适配:autofit.js 按 1920 x 1440 设计稿进行整体缩放。
- 登录认证:企业微信桌面端扫码登录、企业微信工作台 code/token 登录、普通浏览器登录页兜底。
- 数据策略:路由进入时请求配置开关,决定使用真实接口数据还是演示数据。
- 部署链路:Jenkins 按 dev/staging/hotfix/prod 分支或流水线阶段构建并推送到 nginx 静态目录。
核心业务模块
1. 大屏主容器与布局编排
src/views/dashboard/index.vue 负责整体三栏布局:左侧价格与行情,中间交易额与市场动态,右侧 AI 控货与调度。页面不是单纯堆组件,而是承担了大屏级别的数据刷新编排。
实现特点:
- 使用 autofit.init({ dw: 1920, dh: 1440 }) 对大屏进行统一缩放。
- 主容器通过 compRefs 收集需要统一刷新的子组件实例。
- 使用 Pinia 的 dashbordStore.register() 注册子组件暴露的 getDetail 方法。
- 使用 useRafTicker(0, 5000) 形成 5 秒节奏的全局刷新触发。
- dashbordStore.runAll() 并行执行任务,单个任务失败不会阻塞其他模块刷新。
这个设计适合数据大屏:不同业务卡片的数据互相独立,统一调度可以降低页面层定时器散落的问题,并且能集中收集失败任务。
2. 任务中心与容错刷新机制
src/stores/modules/dashbordStore.ts 抽象了一个轻量任务中心:
- register(id, fn, retryInterval) 注册异步任务。
- unregister(id) 注销任务。
- runAll() 并发执行所有任务。
- 单任务最多重试 3 次。
- 失败任务最终以 FailLog[] 形式返回。
难点在于大屏组件数量多、接口多、刷新频率高,如果每个组件各自维护定时器,会造成节奏不可控、请求重叠、异常难定位。这个任务中心把"刷新调度"提升为全局能力,是项目里比较值得提炼的工程亮点。
typescript
const dashbordStore = useDashbordStore();
async function runTasks() {
console.log("[Dashboard] 开始刷新所有数据...", dayjs().format("YYYY-MM-DD HH:mm:ss"));
const fails = await dashbordStore.runAll();
if (fails.length) {
console.warn("[Dashboard] 以下任务失败:", fails);
// 可以在这里全局提示,或者上报监控
}
}
/**
* 收集任务
*/
const onCollectionTasks = () => {
// 清空所有任务
dashbordStore.tasks = [];
// 手动注册任务, 有部分组件是需要自己制定刷新时机的
dashbordStore.register("aggregatePriceIndex", compRefs.aggregatePriceIndex.getDetail);
dashbordStore.register("freightIndex", compRefs.freightIndex.getDetail);
// 中间的
dashbordStore.register("annualTransactionAmount", compRefs.annualTransactionAmount.getDetail);
dashbordStore.register("marketTransactionDynamics", compRefs.marketTransactionDynamics.getDetail);
dashbordStore.register("platformTradingGoods", compRefs.platformTradingGoods.getDetail);
// 右侧
dashbordStore.register("aiDispatchAnalysis", compRefs.aiDispatchAnalysis.getDetail);
console.log(dashbordStore.taskCount);
runTasks(); // 首次执行一次
};
onMounted(() => {
// 输出信息
autofit.init({
el: "#dashbord-container",
// 设计稿尺寸
dw: 1920,
dh: 1440
});
// 注册任务
onCollectionTasks();
});
onBeforeUnmount(() => {
autofit.off();
});
3. 真假数据切换与演示模式
项目通过 getDataConfig() 获取后端开关,dashboardConfigStore.isRealData 决定是否走真实接口。部分模块在演示模式下使用本地 mock,并做了不同展示逻辑:
- 年度累计交易额在演示模式下使用本地初始值,并每小时自动增长。
- 市场交易动态图在演示模式下使用 mockData.ts,并切换为 5 条商品曲线。
- 热门产品、热门航线在演示模式下只展示指定价格字段。
- AI 调度信息在演示模式下对描述文本做脱敏。
这个能力说明项目不是简单的数据展示页,而是兼顾了真实运营和客户/会议演示两类场景。难点在于同一套 UI 要兼容真实数据、演示数据、字段差异和脱敏规则。
4. 复杂图表与动态窗口展示
项目中图表不是静态配置,存在多种动态处理:
- 砂石骨料价格指数图:支持近一周、近一月切换,动态计算 Y 轴 min/max,0 值转为 null,避免断点误展示为真实低值。
- 运价综合指数图:基于 computed 生成 ECharts option,响应日期和筛选条件。
- 市场交易动态图:交易额折线 + 商品散点/曲线混合展示,按 6 个点为窗口每 5 秒滚动切片。
- 平台交易商品图:使用 ECharts GL 的 surface + 参数方程模拟 3D 饼图,并开启自动旋转。
其中 3D 饼图是技术亮点。platformTradeChart.vue 通过 getParametricEquation(startRatio, endRatio, height) 为每个扇区生成参数曲面,根据交易额占比计算曲面角度和高度,再用 grid3D.viewControl.autoRotate 实现自动旋转。这比普通 2D 饼图更贴合大屏的展示冲击力。
5. AI 控货视频监控
aiControlledCargo.vue 是项目里业务复杂度较高的模块。它不仅展示设备数量,还接入船舶视频:
- 调用设备 token 接口获取视频鉴权 token。
- 根据船舶设备 id 拼接视频播放器 iframe 地址。
- 使用 Swiper 以每页两个视频的方式轮播。
- 只给当前页视频填充 url,离开的页清空 url,降低 iframe 播放资源占用。
- 通过 TaskBridge 和 iframe 使用 postMessage 交互,监听 playerError 和 playerSuccess。
- 对播放失败的设备加入 failIds,从后续视频轮播列表中剔除,并尝试用末尾可用设备补位。
这个模块的难点在于外部播放器是独立页面,父页面无法直接读取播放器状态,只能靠跨窗口消息协议做状态回传。它同时涉及鉴权、视频资源懒加载、轮播状态管理、异常设备剔除和 UI 状态同步。
6. 企业微信登录与访问控制
项目覆盖了两种企业微信场景:
- 企业微信工作台内打开:URL 携带 code 或 token,路由守卫中自动登录或写入 token。
- 普通浏览器打开:进入 /login,使用 @wecom/jssdk 创建企业微信登录组件,扫码后拿 code 换 accessToken。
src/routes/index.ts 在路由守卫里处理 token、白名单、企微上下文跳转、登录页兜底和数据开关初始化。src/apis/request.ts 在请求拦截器中附加 Authorization 和灰度版本号,响应拦截器统一处理业务错误、401、网络错误和离线状态。
这块可以提炼为:企业内部系统的统一登录态接入、企微多端入口兼容和权限异常兜底。
7. 数字动效与高频数据展示
项目对关键数字做了多层动效:
- @number-flow/vue 用于普通统计卡片的数字滚动。
- 自定义 flipBoard.vue 和 flipDigit.vue 用于年度累计成交贸易额的翻牌式大数字展示。
- annualTransactionAmount.vue 对主数字动画期间的新值做 pending 缓冲,避免动画中途被刷新打断。
- 多个排行榜和行情卡片使用 transition-group 实现列表进出和字段切换动画。
这说明项目关注的不只是数据正确性,还有大屏观看体验:刷新不能突兀,数据变化要可感知,关键指标要有视觉权重。
项目亮点总结
-
大屏级刷新调度抽象比较清晰,避免所有组件各写一套刷新逻辑。
-
图表类型丰富,覆盖折线、面积、散点、3D 饼图、排行榜、滚动列表、数字翻牌。
-
视频监控模块具备真实业务复杂度:鉴权、取流、轮播、播放状态回传、失败剔除。
-
企业微信登录链路覆盖工作台和桌面浏览器,符合企业内部系统入口特点。
-
演示模式不是简单 mock,而是包含字段裁剪、展示变体、脱敏和定时增长。
-
使用 TypeScript 定义了较完整的接口类型,利于前后端接口协作。
-
使用 autofit.js、字体资源和大量 SVG/WEBP 素材还原大屏设计稿。
其他
useRafTicker
自动开关切换。
typescript
import { ref, onMounted, onUnmounted } from "vue";
/**
* useRafTicker
* 使用 requestAnimationFrame 实现「运行一段时间 → 停顿一段时间 → 再运行」的循环控制器
*
* @param runTime 每次滚动(运行)的时长,单位 ms,默认 2000
* @param pauseTime 每次停顿的时长,单位 ms,默认 500
*
* @returns { pause, start, stop }
* - pause:一个响应式布尔值,true 表示暂停,false 表示滚动中
* - start:手动启动 ticker
* - stop:手动停止 ticker(会清理掉 rAF 循环)
*/
export function useRafTicker(runTime = 2000, pauseTime = 500) {
/**
* 当前是否处于暂停状态
*/
const pause = ref(false);
let frameId: number | null = null; // requestAnimationFrame ID
let lastTime = 0; // 上一次 frame 的时间戳
let elapsed = 0; // 当前状态下累计的时间
let state: "running" | "paused" = "running"; // 当前状态:运行中 / 暂停中
/** 内部调度函数 */
const ticker = (timestamp: number) => {
if (!lastTime) lastTime = timestamp; // 初始化基准时间
const delta = timestamp - lastTime; // 与上一帧的间隔
lastTime = timestamp;
elapsed += delta; // 累积时间
if (state === "running" && elapsed >= runTime) {
// 到达运行时长 → 切换为暂停
pause.value = true;
state = "paused";
elapsed = 0;
} else if (state === "paused" && elapsed >= pauseTime) {
// 到达暂停时长 → 切换为运行
pause.value = false;
state = "running";
elapsed = 0;
}
frameId = requestAnimationFrame(ticker); // 继续下一帧
};
/** 启动 ticker */
const start = () => {
if (frameId) return; // 防止重复启动
lastTime = 0;
elapsed = 0;
state = "running";
pause.value = false;
frameId = requestAnimationFrame(ticker);
};
/** 停止 ticker */
const stop = () => {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
};
// 组件挂载时自动启动
onMounted(start);
// 组件卸载时清理
onUnmounted(stop);
return {
pause,
start,
stop,
};
}
TaskBridge
typescript
/**
* TaskBridge.ts
* --------------------------
* 跨窗口任务桥接工具(父页面 ↔ iframe)
* 支持:
* 1. 父页面调用 iframe 任务
* 2. iframe 调用父页面任务
* 3. 任务支持同步或异步返回值
* 4. 使用 TypeScript 泛型保证 payload 与 result 类型安全
* 5. 支持任意父页面或 iframe 域名(targetOrigin 默认为 "*")
*/
type TaskHandler<Payload = any, Result = any> = (payload?: Payload) => Result | Promise<Result>;
// 任务请求消息结构
interface TaskRequest {
type: "TASK";
taskId: string; // 唯一标识任务
command: string; // 任务名
payload?: any; // 父页面传递的参数
}
// 任务结果消息结构
interface TaskResult {
type: "TASK_RESULT";
taskId: string; // 对应请求的 taskId
result: any; // 执行结果或错误信息
}
// 消息联合类型
type MessageData = TaskRequest | TaskResult;
/**
* TaskBridge 类
* 用于父页面 ↔ iframe 双向任务调用
*/
export default class TaskBridge {
private targetWindow: Window; // 消息目标窗口(iframe.contentWindow 或 window.parent)
private targetOrigin: string; // 目标窗口 origin
private taskIdCounter = 0; // 用于生成唯一 taskId
private pendingTasks = new Map<
string,
{ resolve: (value: any) => void; reject: (reason?: any) => void }
>(); // 存储等待响应的 Promise
private taskRegistry: Record<string, TaskHandler> = {}; // 存储已注册任务
/**
* 构造函数
* @param targetWindow 目标窗口对象
* @param targetOrigin 目标窗口 origin,默认 "*" 表示任意域名
*/
constructor(targetWindow: Window, targetOrigin = "*") {
this.targetWindow = targetWindow;
this.targetOrigin = targetOrigin;
// 监听 postMessage 消息
window.addEventListener("message", (event: MessageEvent<MessageData>) => {
const data = event.data;
// 收到任务请求
if (data.type === "TASK") {
this.handleTask(data);
}
// 收到任务结果
else if (data.type === "TASK_RESULT") {
this.handleResult(data);
}
});
}
/**
* 调用对方任务
* @param command 任务名
* @param payload 可选参数
* @returns Promise<Result> 任务返回结果
*/
call<Payload = any, Result = any>(command: string, payload?: Payload): Promise<Result> {
return new Promise<Result>((resolve, reject) => {
const taskId = `task_${++this.taskIdCounter}`;
this.pendingTasks.set(taskId, { resolve, reject });
const message: TaskRequest = {
type: "TASK",
taskId,
command,
payload,
};
// 发送任务请求到目标窗口
this.targetWindow.postMessage(message, this.targetOrigin);
});
}
/**
* 注册任务
* @param command 任务名
* @param handler 执行任务的函数,可同步或异步返回
*/
register<Payload = any, Result = any>(command: string, handler: TaskHandler<Payload, Result>) {
this.taskRegistry[command] = handler;
}
/**
* 内部方法:处理收到的任务请求
* @param param0 TaskRequest
*/
private async handleTask({ taskId, command, payload }: TaskRequest) {
const handler = this.taskRegistry[command];
if (!handler) {
// 未注册任务返回错误
this.targetWindow.postMessage(
{
type: "TASK_RESULT",
taskId,
result: { error: `Unknown command: ${command}` },
} as TaskResult,
this.targetOrigin
);
return;
}
try {
const result = await handler(payload); // 支持 Promise
this.targetWindow.postMessage(
{ type: "TASK_RESULT", taskId, result } as TaskResult,
this.targetOrigin
);
} catch (err: any) {
this.targetWindow.postMessage(
{ type: "TASK_RESULT", taskId, result: { error: err?.message || String(err) } } as TaskResult,
this.targetOrigin
);
}
}
/**
* 内部方法:处理任务结果
* @param param0 TaskResult
*/
private handleResult({ taskId, result }: TaskResult) {
const pending = this.pendingTasks.get(taskId);
if (pending) {
pending.resolve(result); // resolve 父页面 Promise
this.pendingTasks.delete(taskId);
}
}
}
注册中心设计
typescript
import { defineStore } from "pinia";
export type TaskFunc = (...args: any[]) => Promise<any>;
interface TaskItem {
/**
* 任务名称
*/
id: string | symbol;
/**
* 任务
*/
fn: TaskFunc;
/**
* 可选:自定义重试间隔(ms),默认 1000
*/
retryInterval?: number;
}
interface FailLog {
id: string | symbol;
error: any;
}
/* ---------- 工具 ---------- */
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const useDashbordStore = defineStore("dashbordStore", {
// 1. 状态
state: () => ({
tasks: [] as TaskItem[],
}),
// 2. 计算属性(如需要可扩展)
getters: {
taskCount: state => state.tasks.length,
},
// 3. 方法
actions: {
/** 注册任务(保持原 API) */
register(id: string | symbol, fn: TaskFunc, retryInterval = 1000) {
this.tasks.push({ id, fn, retryInterval });
},
/** 注销任务 */
unregister(id: string | symbol) {
this.tasks = this.tasks.filter(t => t.id !== id);
},
/**
* 执行全部任务
* @param args 透传给每个任务函数
* @returns 最终仍未成功的失败日志数组
*/
async runAll(...args: any[]): Promise<FailLog[]> {
// 并行执行所有任务的 Promise
const executeTask = async (task: TaskItem): Promise<void> => {
let attempt = 0;
const maxAttempts = 3;
let lastError: any;
while (attempt < maxAttempts) {
try {
await task.fn(...args);
return; // 成功执行,直接返回
} catch (err) {
lastError = err;
attempt++;
if (attempt < maxAttempts) {
console.warn(
`[TaskCenter] 任务 ${String(task.id)} 第 ${attempt} 次失败,${attempt}/3,${err},将在 ${task.retryInterval}ms 后重试`
);
await sleep(task.retryInterval!);
}
}
}
// 3 次均失败,抛出错误
throw lastError;
};
// 并行执行所有任务
const promises = this.tasks.map(async task => {
try {
await executeTask(task);
return null; // 成功返回 null
} catch (error) {
// console.error(`[TaskCenter] 任务 ${String(task.id)} 最终失败:`, error);
return { id: task.id, error }; // 失败返回错误信息
}
});
// 等待所有任务完成
const results = await Promise.allSettled(promises);
// 收集失败的日志
const failLogs: FailLog[] = [];
results.forEach((result, index) => {
if (result.status === "fulfilled" && result.value !== null) {
failLogs.push(result.value);
} else if (result.status === "rejected") {
// 这种情况理论上不会发生,因为我们在 map 中已经捕获了错误
failLogs.push({
id: this.tasks[index].id,
error: result.reason,
});
}
});
return failLogs;
},
},
});
基于 GPT-5.4 分析的项目,非常的客观, 将我当时设计和对项目的思考都表述的很到位 。 快用 AI 去试试你自己的项目, 做 项目的分析吧 。