在 RuoYi 中接入 3D「园区驾驶舱」:Vue2 + Three.js + Nginx
目标:把一个 Vue2 + Three.js 的 3D 可视化大屏接入 RuoYi 后台,前后端解耦,统一经网关访问微服务接口;支持运行时切环境,Nginx 一键上线。
技术:Vue2、Three.js、GSAP、Axios、RuoYi-UI、Nginx。
1. 总体思路
静态站:
/screen/
(Nginx 托管)承载打包后的前端。接口前缀:前端一律请求
/api/**
,由 Nginx 反代到网关(如/prod-api/**
)。运行时配置:把 API 前缀、设备 IP 写在
app-config.json
,前端启动时加载——切环境无需重打包。RuoYi 菜单:在“菜单管理”新增菜单,组件路径指向前端页面(或 IFrame 外链)。
2. 目录结构(前端项目)
big-screen-vue-ip
├─ public
│ ├─ app-config.json # 运行时配置(见下)
│ ├─ glb/ # 3D 模型(示例:building-a.glb)
│ ├─ data/ # 数据(routes/object.json 等)
│ └─ index.html
├─ src
│ ├─ views/park3d.vue # 3D 场景页面(核心)
│ └─ main.js
├─ package.json
└─ vue.config.js # publicPath = './'(推荐)
3. 运行时配置:public/app-config.json
{"API_BASE": "/api","CAM_IP": "<CAMERA_IP_PLACEHOLDER>"
}
占位说明:
<CAMERA_IP_PLACEHOLDER>
:设备/摄像头 IP,按需替换;API_BASE
:前端统一调用前缀,始终写/api
,由 Nginx 去代理到网关。
4. Axios 初始化与 Token 透传(节选)
// src/views/park3d.vue(或 main.js)
import axios from 'axios';const BASE = process.env.BASE_URL || './'; // 打包后为 /screen/async function loadInit() {const cfg = (await axios.get(BASE + 'app-config.json')).data;// 统一接口前缀axios.defaults.baseURL = cfg.API_BASE || '/api';// (可选)从 ?token= 读取令牌,自动挂到 Authorizationconst token = new URLSearchParams(location.search).get('token');if (token) {axios.interceptors.request.use(c => {c.headers.Authorization = 'Bearer ' + token;return c;});}// 业务自用示例this.cameraIp = cfg.CAM_IP;
}
说明:静态文件(/screen/data/*.json)不要用 axios 默认实例读取,否则会被拼到 /api/...
。下文给出用 fetch
的正确方式。
5. 静态 JSON 与模型加载:避免路径踩坑
5.1 用 fetch
读取静态 JSON(不要走 axios.baseURL)
async function loadData() {try {// 正确写法:fetch 走相对路径 /screen/data/*.json,不受 axios.baseURL 影响const roads = await (await fetch(BASE + 'data/routes.json', { cache: 'no-cache' })).json();const objects = await (await fetch(BASE + 'data/object.json', { cache: 'no-cache' })).json();this.parseRoads(roads);this.objects = objects;} catch (e) {console.error('Error loading data:', e);}
}
5.2 统一模型路径:normalizeModelPath
+ GLTFLoader.setPath
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';const GLB_BASE = BASE + 'glb/';function normalizeModelPath(file) {if (!file) return file;let f = String(file).trim().replace(/\\/g, '/');if (f.includes('://')) return f; // 完整 URL 原样返回f = f.replace(/\.gltf(\?.*)?$/i, '.glb$1'); // 统一换 .glbif (/^\/?gltf\//i.test(f)) f = f.replace(/^\/?gltf\//i, 'glb/');if (/^\/?glb\//i.test(f)) f = f.replace(/^\/?glb\//i, '');if (f.startsWith('/')) return BASE + f.slice(1); // /xxx → /screen/xxxreturn f; // 文件名交给 setPath(GLB_BASE) 去拼
}// 创建 loader 并设置基础路径
const loader = new GLTFLoader();
loader.setPath(GLB_BASE);// 用法示例:加载单个可点击模型
function loadModel(file, name, position, scale, rotation, userTag = null) {const url = normalizeModelPath(file);loader.load(url, gltf => {const model = gltf.scene;// 可点击标记const clickable = !!userTag;model.userData.clickable = clickable;model.userData.tag = userTag;// 材质统一处理(半透明等)const setOpacity = mesh => {const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];mats.forEach(m => {m.transparent = true;m.opacity = 0.8;m.depthWrite = m.opacity >= 1.0;m.alphaTest = 0.1;m.needsUpdate = true;});};model.traverse(c => c.isMesh && setOpacity(c));// 位置缩放旋转model.position.set(position.x, position.y, position.z);model.scale.set(scale.x, scale.y, scale.z);model.rotation.set(THREE.MathUtils.degToRad(rotation.x),THREE.MathUtils.degToRad(rotation.y),THREE.MathUtils.degToRad(rotation.z));this.scene.add(model);if (clickable) this.clickableObjects.push(model);}, undefined, err => console.error('GLB load error:', err));
}
6. vue.config.js
(推荐)
// vue.config.js
module.exports = {publicPath: './', // 关键:发布到 /screen/ 子路径时资源可用devServer: {host: '0.0.0.0',port: 8090,proxy: {// 本地开发时,前端请求 /api/** 转发到网关'/api': {target: 'http://localhost:8080', // ← 你的网关changeOrigin: true,pathRewrite: { '^/api': '/prod-api' }}}}
};
7. RuoYi 菜单与路由(避免“两个菜单”)
只用“菜单管理”下发动态路由;不要在
src/router/index.js
的constantRoutes
里再手写/screen
。菜单配置建议:
菜单类型:菜单
路由地址:
/screen
(顶级菜单加/
)组件路径:
monitor/screen/index
(你的页面路径)是否外链:否;显示:是;缓存:按需
如果你要用 IFrame 方式:把是否外链设为“是”,外链地址填
http://<YOUR_HOST>:8089/screen/
(注意尾斜杠),打开方式选“内嵌”。
8. Nginx 配置(server 片段)
文件:
C:\nginx\nginx-1.24.0\conf\nginx.conf
(Windows 示例)
server {listen 8089;server_name <YOUR_HOST>;# 访问 /screen 会 301 到 /screen/(防止静态相对路径跑偏)location = /screen { return 301 /screen/; }# 大屏静态站(把 dist/ 拷到 C:/nginx/nginx-1.24.0/screen)location /screen/ {root C:/nginx/nginx-1.24.0;try_files $uri $uri/ /screen/index.html; # SPA 刷新兜底}# 前端统一 /api → 网关(按需调整)location /api/ {proxy_pass http://<GATEWAY_HOST>:<GATEWAY_PORT>/prod-api/;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_http_version 1.1;}
}
把 <YOUR_HOST>
、<GATEWAY_HOST>
、<GATEWAY_PORT>
改成你的环境;生产建议用域名。
9. Windows 一键启动
文件:C:\nginx\nginx-1.24.0
taskkill //F //IM nginx.exe 2>/dev/null
cd /c/nginx/nginx-1.24.0
./nginx.exe -t && ./nginx.exe
10. 常见报错与快速定位
GLTFLoader 报 “
<!doctype ... is not valid JSON
”.glb
请求 404/被重写 —— 检查GLTFLoader.setPath(BASE + 'glb/')
与normalizeModelPath
。访问地址缺尾斜杠(
/screen
)导致相对路径错位 —— 加location = /screen { return 301 /screen/; }
,在浏览器保证地址是/screen/
。
读取 JSON 报
iterator/forEach undefined
用了 axios 实例读静态 JSON → 被拼成
/api/screen/data/...
。改用fetch(BASE + 'data/*.json')
。
侧栏出现两个“可视化大屏”
你同时在“菜单管理”建了菜单,又在
constantRoutes
手写了一条/screen
。删掉手写常量路由即可。
CORS 跨域
保证前端只访问
/api/**
,由 Nginx 反代到网关域名/端口;不要在前端直接写网关的全量域名。
11. 小结
这套方案把前端静态和后端网关彻底解耦:
前端只认
/api
,Nginx 负责转发到网关;环境切换只改
app-config.json
;发布就是“拷贝 dist → /screen + 重启 Nginx”。