使用 codex + GPT 5.4 分析已实现的 数据看板

项目定位

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 去试试你自己的项目, 做 项目的分析吧 。

相关推荐
qq_12084093712 小时前
Three.js 工程向:相机控制与交互手感调优(OrbitControls)
前端·javascript·orbitcontrols
疯狂的魔鬼2 小时前
从 5 个 Hooks 到注册表模式:Vue 3 复杂详情页的架构演进与原则沉淀
前端·架构
enoughisenough2 小时前
WEB网络通信
前端
We་ct2 小时前
LeetCode 300. 最长递增子序列:两种解法从入门到优化
开发语言·前端·javascript·算法·leetcode·typescript
深海鱼在掘金2 小时前
Next.js从入门到实战保姆级教程(第一章):导读——建立 Next.js 的认知框架
前端·typescript·next.js
渔舟小调2 小时前
P17 | 管理台动态路由:后端返回菜单树,前端运行时注入
前端
小徐_23333 小时前
uni-app 组件库 Wot UI 2.0 发布了,我们带来了这些改变!
前端·微信小程序·uni-app
❀͜͡傀儡师3 小时前
Claude Code 官方弃用 npm 安装方式:原因分析与完整迁移指南
前端·npm·node.js·claude code
知识分享小能手3 小时前
ECharts入门学习教程,从入门到精通,ECharts高级功能(6)
前端·学习·echarts