目标:把一个 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 透传(节选)
javascript
// 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= 读取令牌,自动挂到 Authorization
const 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)
javascript
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
javascript
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'); // 统一换 .glb
if (/^\/?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/xxx
return 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
(推荐)
javascript
// 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 示例)
javascript
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
bash
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')
。
- 用了 axios 实例读静态 JSON → 被拼成
-
侧栏出现两个"可视化大屏"
- 你同时在"菜单管理"建了菜单,又在
constantRoutes
手写了一条/screen
。删掉手写常量路由即可。
- 你同时在"菜单管理"建了菜单,又在
-
CORS 跨域
- 保证前端只访问
/api/**
,由 Nginx 反代到网关域名/端口;不要在前端直接写网关的全量域名。
- 保证前端只访问
11. 小结
这套方案把前端静态和后端网关彻底解耦:
-
前端只认
/api
,Nginx 负责转发到网关; -
环境切换只改
app-config.json
; -
发布就是"拷贝 dist → /screen + 重启 Nginx"。