上线飞牛插件3d模型查看器(glbload)
/01.jpg)
初衷
我写这个插件的原因很简单,因为我闺女特别喜欢看3D 模型,看着她拿着鼠标晃来晃去模型也跟着动来动去的开心的样子,我就很欣慰了,因为模型要比平面的书本给她更好的体验。所以我在发现平时很难在各种设备上随手打开 GLB 文件:软件不统一、路径不方便、网络条件也不总是理想。于是这件事有了明确目标:
- 在任何时候、任何网络、任何支持飞牛的设备上,随时随地查看家里的 3D 模型文件
- 把“打开模型”这件事变得像点开照片一样顺手
工作场景也同样需要它:有些团队要管理和查看大量 GLB 文件,统一入口、统一权限、统一错误提示,能节省沟通时间,也能降低协作成本。
如何找到插件
你可以在飞牛的应用市场搜索 leelaa或者 glbload,就能看到这个插件。
前言
这次做 glbload飞牛插件,最大的收获不是功能,而是过程。启用时的那句
server.mjs not found
像一记重锤敲在脑门上。反复看日志、对路径、改脚本,才意识到:结构没对上,怎么可能跑起来。
关键原则(先说结论)
- 后端服务必须打包到:
fnConfig/app/server - 前端必须打包到:
fnConfig/app/ui
否则平台不会把它们释放到 @appcenter,启动脚本也就“找不到入口”。这两个路径,是整个插件能不能“活过来”的底线。
排查与修正
入口找不到
启用后只看到:
server.mjs not found: /vol4/@appcenter/.../app/server/server.mjs
一开始以为漏打包,其实是“落点错了”。我把后端放在了错误的目录。严格改为 fnConfig/app/server(后端)与 fnConfig/app/ui(前端)后,日志立刻能定位到入口。
运行时崩溃(ESM/CJS)
报错:
Dynamic require of "path" is not supported
把后端改为 CommonJS(server.cjs),并用 process.argv[1] 推导运行文件路径,稳定解决。
403 提示更明确(感谢飞牛审核人员的建议)
路径访问走平台授权。后端只做“存在/可读/越界”校验。前端遇到 403,用更直白的提示:
无权限打开,请右键文件设置权限或在应用设置中调整授权目录范围
飞牛的容器不支持全屏
所以我在前端去掉全屏的功能,希望以后能增加更多官方的api。最好能提供nodejs的sdk。这样也更方便开发者进行工作了。
配置多个入口
这个type是有两个选择,如果选择url的话,可以在新标签页打开,如果选择iframe的话,就是在飞牛的应用内打开。
以下是我的配置文件 fnConfig\app\ui\config的内容:
{
".url": {
"leelaa.glbload.Application": {
"title": "GLB 查看器",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"port": "5073",
"url": "/",
"allUsers": true
},
"leelaa.glbload.viewer": {
"title": "GLB 查看器(窗口)",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"port": "5073",
"url": "/viewer",
"allUsers": true,
"fileTypes": [
"glb",
"gltf",
"GLB",
"GLTF"
],
"noDisplay": true,
"control": {
"accessPerm": "readonly",
"portPerm": "readonly",
"pathPerm": "readonly"
}
},
"leelaa.glbload.externalViewer": {
"title": "GLB 查看器(外部)",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "http",
"port": "5073",
"url": "/viewer",
"allUsers": true,
"fileTypes": [
"glb",
"gltf",
"GLB",
"GLTF"
],
"noDisplay": true,
"control": {
"accessPerm": "readonly",
"portPerm": "readonly",
"pathPerm": "readonly"
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
一键打包脚本(build-all.js)
这段脚本是“把前端和后端放到正确位置”的关键。它统一了落点、消除了历史残留,并产出 .fpk,仅供参考,和官方的
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import esbuild from "esbuild";
import { spawnSync } from "child_process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, "..");
const distDir = path.join(root, "release", "frontend");
const releaseDir = path.join(root, "release");
const feOut = path.join(releaseDir, "frontend");
const beOut = path.join(releaseDir, "app", "server");
const uiOut = path.join(releaseDir, "app", "ui");
const extraDir = path.join(root, "fnConfig");
function readText(file) {
try {
return fs.readFileSync(file, "utf8");
} catch {
return "";
}
}
function parseManifest(txt) {
const info = {};
txt.split(/\r?\n/).forEach((line) => {
const m = line.match(/^([^=]+)=(.*)$/);
if (m) info[m[1].trim()] = m[2].trim();
});
return info;
}
function run(cmd, args, cwd) {
const r = spawnSync(cmd, args, { cwd, stdio: "inherit" });
return r.status === 0;
}
function rm(dir) {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function ensure(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function copy(src, dst) {
const st = fs.statSync(src);
if (st.isDirectory()) {
ensure(dst);
for (const name of fs.readdirSync(src)) {
copy(path.join(src, name), path.join(dst, name));
}
} else {
ensure(path.dirname(dst));
fs.copyFileSync(src, dst);
}
}
ensure(releaseDir);
if (!fs.existsSync(distDir)) {
process.stderr.write("frontend build not found. run 'npm run build' first\n");
process.exit(1);
}
ensure(feOut);
rm(beOut);
ensure(beOut);
ensure(uiOut);
rm(path.join(releaseDir, "server"));
try { fs.unlinkSync(path.join(releaseDir, "app", "server.mjs")); } catch {}
ensure(path.join(beOut, "uploads"));
copy(distDir, uiOut);
await esbuild.build({
entryPoints: [path.join(root, "server", "index.js")],
outfile: path.join(beOut, "server.cjs"),
bundle: true,
platform: "node",
format: "cjs",
target: ["node22"],
minify: false,
sourcemap: false
});
const wizardOut = path.join(releaseDir, "wizard");
rm(wizardOut);
if (fs.existsSync(extraDir)) {
for (const name of fs.readdirSync(extraDir)) {
const src = path.join(extraDir, name);
const dst = path.join(releaseDir, name);
copy(src, dst);
}
}
const buildRoot = path.join(root);
rm(path.join(releaseDir, "package"));
const manifestSrc = path.join(releaseDir, "manifest");
const manifestTxt = readText(manifestSrc);
if (!manifestTxt) {
process.stderr.write("manifest not found in release\n");
process.exit(1);
}
const info = parseManifest(manifestTxt);
const appName = info.appname || "app";
const version = info.version || "0.0.0";
const packDir = path.join(buildRoot, appName);
rm(packDir);
ensure(packDir);
for (const name of fs.readdirSync(releaseDir)) {
if (name === "package") continue;
if (name.endsWith('.fpk')) continue;
copy(path.join(releaseDir, name), path.join(packDir, name));
}
const fnpackCmd = process.platform === "win32" ? path.join(root, "scripts", "fnpack.exe") : "fnpack";
const ok = run(fnpackCmd, ["build"], packDir);
if (!ok) {
process.stderr.write("fnpack build failed or fnpack not found in PATH\n");
process.exit(1);
}
const srcFpk = path.join(packDir, `${appName}.fpk`);
const dstFpkVer = path.join(buildRoot, `${appName}-${version}.fpk`);
if (fs.existsSync(srcFpk)) {
fs.copyFileSync(srcFpk, dstFpkVer);
try { fs.unlinkSync(srcFpk); } catch {}
}
rm(packDir);
process.stdout.write(`Release built at ${releaseDir}\n`);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
结语
感谢内测群的小伙伴;也感谢那些让我少走弯路的优秀项目。愿飞牛生态越来越好,我们在平台上写出更多“跑得稳、用得顺”的好插件。