Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd24acf19 | ||
|
|
37e1e58883 | ||
|
|
a8947696af | ||
|
|
9662f41eec | ||
|
|
fbfaa59b14 | ||
|
|
27b4b720ed | ||
|
|
e62f2b54d5 | ||
|
|
d7c36e31f7 | ||
|
|
985c8d39fc | ||
|
|
5368e52f0d | ||
|
|
83684b4c7b | ||
|
|
6720a3e730 | ||
|
|
e78afc73f3 | ||
|
|
2c62987281 | ||
|
|
ecddca00c9 | ||
|
|
8b363e8eb4 | ||
|
|
43c2be9525 | ||
|
|
838939855e | ||
|
|
e8615c6796 | ||
|
|
d8f98ee295 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,364 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TkAstral3D 非分包离线测试</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: rgba(15, 23, 42, 0.88);
|
||||||
|
--line: rgba(148, 163, 184, 0.24);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--accent-2: #38bdf8;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b1020, #050814 72%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 20px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc,
|
||||||
|
.hint,
|
||||||
|
.meta,
|
||||||
|
.log {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(2, 6, 23, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="file"] {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h1>Tk 非分包离线测试</h1>
|
||||||
|
<div class="desc">
|
||||||
|
这个页面直接使用 Tk 构建产物来验证离线包加载。
|
||||||
|
仅支持非分包单文件模式:选择 1 个 .astral 文件加载。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>选择离线包</h2>
|
||||||
|
<input id="fileInput" type="file" accept=".astral" />
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadBtn">加载离线包</button>
|
||||||
|
<button id="loadLocalBtn" class="secondary">直接加载本目录</button>
|
||||||
|
<button id="resetBtn" class="secondary">重置场景</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status">等待选择 .astral 文件,或点击“直接加载本目录”</div>
|
||||||
|
<ul id="fileList" class="file-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>说明(非分包)</h2>
|
||||||
|
<div class="hint">
|
||||||
|
1. 仅选择一个非分包导出的 .astral 文件。\n
|
||||||
|
2. 页面使用 SDK 的 Package.unpack 直接加载,不依赖页面层 JSZip。\n
|
||||||
|
3. 也可点击“直接加载本目录”,默认读取 1121212.astral。\n
|
||||||
|
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<div id="viewer"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./dist/astral3d.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const SDK = globalThis.Astral3D;
|
||||||
|
if (!SDK) {
|
||||||
|
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
|
||||||
|
document.getElementById("status").className = "status error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerEl = document.getElementById("viewer");
|
||||||
|
const fileInput = document.getElementById("fileInput");
|
||||||
|
const loadBtn = document.getElementById("loadBtn");
|
||||||
|
const loadLocalBtn = document.getElementById("loadLocalBtn");
|
||||||
|
const resetBtn = document.getElementById("resetBtn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const fileListEl = document.getElementById("fileList");
|
||||||
|
const LOCAL_SINGLE_FILE = "1121212.astral";
|
||||||
|
|
||||||
|
let viewer = null;
|
||||||
|
|
||||||
|
function setStatus(text, type) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = "status" + (type ? ` ${type}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewer() {
|
||||||
|
if (viewer) return viewer;
|
||||||
|
viewer = new SDK.Viewer({
|
||||||
|
container: viewerEl,
|
||||||
|
edit: { enabled: false },
|
||||||
|
grid: { enabled: true },
|
||||||
|
request: { baseUrl: "" },
|
||||||
|
});
|
||||||
|
globalThis.viewer = viewer;
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewer() {
|
||||||
|
if (viewer && typeof viewer.dispose === "function") {
|
||||||
|
viewer.dispose();
|
||||||
|
}
|
||||||
|
viewer = null;
|
||||||
|
viewerEl.innerHTML = "";
|
||||||
|
setStatus("场景已重置", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList(files) {
|
||||||
|
fileListEl.innerHTML = "";
|
||||||
|
Array.from(files || []).forEach(file => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
|
||||||
|
fileListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadByOfflineBundle(bundle, entryName) {
|
||||||
|
setStatus("正在准备离线包...", "");
|
||||||
|
const currentViewer = ensureViewer();
|
||||||
|
const packer = currentViewer.package || new SDK.Package(currentViewer);
|
||||||
|
currentViewer.package = packer;
|
||||||
|
|
||||||
|
setStatus(`开始加载离线包:${entryName}`, "");
|
||||||
|
|
||||||
|
await packer.unpack({
|
||||||
|
url: entryName,
|
||||||
|
offlineBundle: bundle,
|
||||||
|
offlineEntry: entryName,
|
||||||
|
onProgress: progress => {
|
||||||
|
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
|
||||||
|
},
|
||||||
|
onSceneLoad: () => {
|
||||||
|
setStatus("场景首包已解析,正在继续加载资源...", "");
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setStatus(`离线包加载完成:${entryName}`, "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLocalAstralFile(name) {
|
||||||
|
const response = await fetch(`./${encodeURIComponent(name)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取本地文件失败:${name}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], name, { type: "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSingleAstralFromFileList(fileList) {
|
||||||
|
const files = Array.from(fileList || []).filter(file => file.name.toLowerCase().endsWith(".astral"));
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error("请选择 .astral 文件");
|
||||||
|
}
|
||||||
|
if (files.length !== 1) {
|
||||||
|
throw new Error("非分包模式请只选择 1 个 .astral 文件");
|
||||||
|
}
|
||||||
|
return files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfflinePackage() {
|
||||||
|
try {
|
||||||
|
const file = getSingleAstralFromFileList(fileInput.files);
|
||||||
|
renderFileList([file]);
|
||||||
|
await loadByOfflineBundle(file, file.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalPackage() {
|
||||||
|
try {
|
||||||
|
renderFileList([{ name: LOCAL_SINGLE_FILE, size: 0 }]);
|
||||||
|
const file = await fetchLocalAstralFile(LOCAL_SINGLE_FILE);
|
||||||
|
await loadByOfflineBundle(file, LOCAL_SINGLE_FILE);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`本目录加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
renderFileList(fileInput.files);
|
||||||
|
setStatus("文件已选择,点击“加载离线包”开始测试", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBtn.addEventListener("click", () => {
|
||||||
|
loadOfflinePackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLocalBtn.addEventListener("click", () => {
|
||||||
|
loadLocalPackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn.addEventListener("click", () => {
|
||||||
|
resetViewer();
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureViewer();
|
||||||
|
setStatus("Tk SDK 已加载(非分包模式),等待选择离线包", "success");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TkAstral3D 分包离线测试</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: rgba(15, 23, 42, 0.88);
|
||||||
|
--line: rgba(148, 163, 184, 0.24);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--accent-2: #38bdf8;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b1020, #050814 72%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 20px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc,
|
||||||
|
.hint,
|
||||||
|
.meta,
|
||||||
|
.log {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(2, 6, 23, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="file"] {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h1>Tk 分包离线测试</h1>
|
||||||
|
<div class="desc">
|
||||||
|
这个页面直接使用 Tk 构建产物来验证离线包加载。
|
||||||
|
仅支持分包模式:请一次选择多个 .astral 文件并加载。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>选择离线包</h2>
|
||||||
|
<input id="fileInput" type="file" multiple accept=".astral" />
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadBtn">加载离线包</button>
|
||||||
|
<button id="loadLocalBtn" class="secondary">直接加载本目录</button>
|
||||||
|
<button id="resetBtn" class="secondary">重置场景</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status">等待选择 .astral 文件,或点击“直接加载本目录”</div>
|
||||||
|
<ul id="fileList" class="file-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>说明(分包)</h2>
|
||||||
|
<div class="hint">
|
||||||
|
1. 请选择导出的全部分包 .astral 文件。\n
|
||||||
|
2. 至少包含入口包和依赖包后再点击加载。\n
|
||||||
|
3. 也可点击“直接加载本目录”,默认读取 1121212.astral + 7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral。\n
|
||||||
|
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<div id="viewer"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./dist/astral3d.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const SDK = globalThis.Astral3D;
|
||||||
|
if (!SDK) {
|
||||||
|
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
|
||||||
|
document.getElementById("status").className = "status error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerEl = document.getElementById("viewer");
|
||||||
|
const fileInput = document.getElementById("fileInput");
|
||||||
|
const loadBtn = document.getElementById("loadBtn");
|
||||||
|
const loadLocalBtn = document.getElementById("loadLocalBtn");
|
||||||
|
const resetBtn = document.getElementById("resetBtn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const fileListEl = document.getElementById("fileList");
|
||||||
|
const LOCAL_SPLIT_FILES = [
|
||||||
|
"1121212.astral",
|
||||||
|
"7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral",
|
||||||
|
];
|
||||||
|
|
||||||
|
let viewer = null;
|
||||||
|
|
||||||
|
function setStatus(text, type) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = "status" + (type ? ` ${type}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessEntryFile(files) {
|
||||||
|
const uuidReg = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||||
|
const findBaseName = name => name.replace(/\.[^.]+$/, "");
|
||||||
|
return files.find(file => !uuidReg.test(findBaseName(file.name))) || files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewer() {
|
||||||
|
if (viewer) return viewer;
|
||||||
|
viewer = new SDK.Viewer({
|
||||||
|
container: viewerEl,
|
||||||
|
edit: { enabled: false },
|
||||||
|
grid: { enabled: true },
|
||||||
|
request: { baseUrl: "" },
|
||||||
|
});
|
||||||
|
globalThis.viewer = viewer;
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewer() {
|
||||||
|
if (viewer && typeof viewer.dispose === "function") {
|
||||||
|
viewer.dispose();
|
||||||
|
}
|
||||||
|
viewer = null;
|
||||||
|
viewerEl.innerHTML = "";
|
||||||
|
setStatus("场景已重置", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList(files) {
|
||||||
|
fileListEl.innerHTML = "";
|
||||||
|
Array.from(files || []).forEach(file => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
|
||||||
|
fileListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadByOfflineFiles(files, entryName) {
|
||||||
|
setStatus(`开始加载离线包:${entryName}`, "");
|
||||||
|
const currentViewer = ensureViewer();
|
||||||
|
const packer = currentViewer.package || new SDK.Package(currentViewer);
|
||||||
|
currentViewer.package = packer;
|
||||||
|
|
||||||
|
await packer.unpack({
|
||||||
|
url: entryName,
|
||||||
|
offlineFiles: files,
|
||||||
|
offlineEntry: entryName,
|
||||||
|
onProgress: progress => {
|
||||||
|
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
|
||||||
|
},
|
||||||
|
onSceneLoad: () => {
|
||||||
|
setStatus("场景首包已解析,正在继续加载资源...", "");
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setStatus(`离线包加载完成:${entryName}`, "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLocalAstralFile(name) {
|
||||||
|
const response = await fetch(`./${encodeURIComponent(name)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取本地文件失败:${name}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], name, { type: "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfflinePackage() {
|
||||||
|
const files = Array.from(fileInput.files || []).filter(f => f.name.toLowerCase().endsWith(".astral"));
|
||||||
|
if (files.length < 2) {
|
||||||
|
setStatus("分包模式请至少选择 2 个 .astral 文件", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFileList(files);
|
||||||
|
try {
|
||||||
|
const entry = guessEntryFile(files);
|
||||||
|
await loadByOfflineFiles(files, entry.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalPackages() {
|
||||||
|
try {
|
||||||
|
renderFileList(LOCAL_SPLIT_FILES.map(name => ({ name, size: 0 })));
|
||||||
|
const files = [];
|
||||||
|
for (const name of LOCAL_SPLIT_FILES) {
|
||||||
|
files.push(await fetchLocalAstralFile(name));
|
||||||
|
}
|
||||||
|
await loadByOfflineFiles(files, LOCAL_SPLIT_FILES[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`本目录加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
renderFileList(fileInput.files);
|
||||||
|
setStatus("文件已选择,点击“加载离线包”开始测试", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBtn.addEventListener("click", () => {
|
||||||
|
loadOfflinePackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLocalBtn.addEventListener("click", () => {
|
||||||
|
loadLocalPackages();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn.addEventListener("click", () => {
|
||||||
|
resetViewer();
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureViewer();
|
||||||
|
setStatus("Tk SDK 已加载(分包模式),等待选择离线包", "success");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+425
@@ -0,0 +1,425 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TkAstral3D Offline Package Test</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: rgba(15, 23, 42, 0.88);
|
||||||
|
--line: rgba(148, 163, 184, 0.24);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--accent-2: #38bdf8;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b1020, #050814 72%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 20px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc,
|
||||||
|
.hint,
|
||||||
|
.meta,
|
||||||
|
.log {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(2, 6, 23, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="file"] {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h1>Tk 离线包测试</h1>
|
||||||
|
<div class="desc">
|
||||||
|
这个页面直接使用 Tk 构建产物来验证离线包加载。
|
||||||
|
支持单包与多包 .astral 文件,直接通过 SDK 的离线参数加载,不依赖编辑器页面。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>选择离线包</h2>
|
||||||
|
<input id="fileInput" type="file" multiple accept=".astral" />
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadBtn">加载离线包</button>
|
||||||
|
<button id="loadLocalSplitBtn" class="secondary">直接加载本目录(分包)</button>
|
||||||
|
<button id="loadLocalSingleBtn" class="secondary">直接加载本目录(单包)</button>
|
||||||
|
<button id="resetBtn" class="secondary">重置场景</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status">等待选择 .astral 文件,或使用“直接加载本目录”</div>
|
||||||
|
<ul id="fileList" class="file-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>说明</h2>
|
||||||
|
<div class="hint">
|
||||||
|
1. 多包模式:直接选中全部 .astral 文件后加载。\n
|
||||||
|
2. 单包模式:只需要选择导出的单个 .astral 文件。\n
|
||||||
|
3. 本目录快捷加载:分包=1121212.astral + 7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral;单包=1121212.astral。\n
|
||||||
|
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<div id="viewer"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./dist/astral3d.umd.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const SDK = globalThis.Astral3D;
|
||||||
|
if (!SDK) {
|
||||||
|
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
|
||||||
|
document.getElementById("status").className = "status error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerEl = document.getElementById("viewer");
|
||||||
|
const fileInput = document.getElementById("fileInput");
|
||||||
|
const loadBtn = document.getElementById("loadBtn");
|
||||||
|
const loadLocalSplitBtn = document.getElementById("loadLocalSplitBtn");
|
||||||
|
const loadLocalSingleBtn = document.getElementById("loadLocalSingleBtn");
|
||||||
|
const resetBtn = document.getElementById("resetBtn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const fileListEl = document.getElementById("fileList");
|
||||||
|
const LOCAL_SPLIT_FILES = [
|
||||||
|
"1121212.astral",
|
||||||
|
"7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral",
|
||||||
|
];
|
||||||
|
const LOCAL_SINGLE_FILE = "1121212-offline.astral";
|
||||||
|
|
||||||
|
let viewer = null;
|
||||||
|
|
||||||
|
function setStatus(text, type) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = "status" + (type ? ` ${type}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessEntryFile(files) {
|
||||||
|
const uuidReg = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||||
|
return files.find(f => !uuidReg.test(f.name.replace(/\.[^.]+$/, ""))) || files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewer() {
|
||||||
|
if (viewer) return viewer;
|
||||||
|
viewer = new SDK.Viewer({
|
||||||
|
container: viewerEl,
|
||||||
|
edit: { enabled: false },
|
||||||
|
grid: { enabled: true },
|
||||||
|
request: { baseUrl: "" },
|
||||||
|
});
|
||||||
|
globalThis.viewer = viewer;
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewer() {
|
||||||
|
if (viewer && typeof viewer.dispose === "function") {
|
||||||
|
viewer.dispose();
|
||||||
|
}
|
||||||
|
viewer = null;
|
||||||
|
viewerEl.innerHTML = "";
|
||||||
|
setStatus("场景已重置", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList(files) {
|
||||||
|
fileListEl.innerHTML = "";
|
||||||
|
Array.from(files || []).forEach(file => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
|
||||||
|
fileListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadByOfflineFiles(files, entryName) {
|
||||||
|
setStatus(`开始加载离线包:${entryName}`, "");
|
||||||
|
const currentViewer = ensureViewer();
|
||||||
|
const packer = currentViewer.package || new SDK.Package(currentViewer);
|
||||||
|
currentViewer.package = packer;
|
||||||
|
|
||||||
|
await packer.unpack({
|
||||||
|
url: entryName,
|
||||||
|
offlineFiles: files,
|
||||||
|
offlineEntry: entryName,
|
||||||
|
onProgress: progress => {
|
||||||
|
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
|
||||||
|
},
|
||||||
|
onSceneLoad: () => {
|
||||||
|
setStatus("场景首包已解析,正在继续加载资源...", "");
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setStatus(`离线包加载完成:${entryName}`, "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadByOfflineBundle(bundle, entryName) {
|
||||||
|
setStatus(`开始加载离线包:${entryName}`, "");
|
||||||
|
const currentViewer = ensureViewer();
|
||||||
|
const packer = currentViewer.package || new SDK.Package(currentViewer);
|
||||||
|
currentViewer.package = packer;
|
||||||
|
console.log({
|
||||||
|
url: "/",
|
||||||
|
offlineBundle: bundle,
|
||||||
|
offlineEntry: entryName,
|
||||||
|
onProgress: progress => {
|
||||||
|
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
|
||||||
|
},
|
||||||
|
onSceneLoad: () => {
|
||||||
|
setStatus("场景首包已解析,正在继续加载资源...", "");
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setStatus(`离线包加载完成:${entryName}`, "success");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await packer.unpack({
|
||||||
|
url: "/",
|
||||||
|
offlineBundle: bundle,
|
||||||
|
offlineEntry: entryName,
|
||||||
|
onProgress: progress => {
|
||||||
|
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
|
||||||
|
},
|
||||||
|
onSceneLoad: () => {
|
||||||
|
setStatus("场景首包已解析,正在继续加载资源...", "");
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setStatus(`离线包加载完成:${entryName}`, "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLocalAstralFile(name) {
|
||||||
|
const response = await fetch(`./${encodeURIComponent(name)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取本地文件失败:${name}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], name, { type: "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfflinePackage() {
|
||||||
|
const files = Array.from(fileInput.files || []).filter(f => f.name.toLowerCase().endsWith(".astral"));
|
||||||
|
if (files.length === 0) {
|
||||||
|
setStatus("请选择 .astral 文件", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFileList(files);
|
||||||
|
try {
|
||||||
|
if (files.length === 1) {
|
||||||
|
await loadByOfflineBundle(files[0], files[0].name);
|
||||||
|
} else {
|
||||||
|
const entry = guessEntryFile(files);
|
||||||
|
await loadByOfflineFiles(files, entry.name);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalSplitPackage() {
|
||||||
|
try {
|
||||||
|
renderFileList(LOCAL_SPLIT_FILES.map(name => ({ name, size: 0 })));
|
||||||
|
const files = [];
|
||||||
|
for (const name of LOCAL_SPLIT_FILES) {
|
||||||
|
files.push(await fetchLocalAstralFile(name));
|
||||||
|
}
|
||||||
|
await loadByOfflineFiles(files, LOCAL_SPLIT_FILES[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`本目录分包加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalSinglePackage() {
|
||||||
|
try {
|
||||||
|
renderFileList([{ name: LOCAL_SINGLE_FILE, size: 0 }]);
|
||||||
|
const file = await fetchLocalAstralFile(LOCAL_SINGLE_FILE);
|
||||||
|
await loadByOfflineBundle(file, LOCAL_SINGLE_FILE);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`本目录单包加载失败:${error && error.message ? error.message : error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
renderFileList(fileInput.files);
|
||||||
|
setStatus("文件已选择,点击“加载离线包”开始测试", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBtn.addEventListener("click", () => {
|
||||||
|
loadOfflinePackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLocalSplitBtn.addEventListener("click", () => {
|
||||||
|
loadLocalSplitPackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLocalSingleBtn.addEventListener("click", () => {
|
||||||
|
loadLocalSinglePackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn.addEventListener("click", () => {
|
||||||
|
resetViewer();
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureViewer();
|
||||||
|
setStatus("Tk SDK 已加载,等待选择离线包", "success");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,21 +0,0 @@
|
|||||||
1. 脚本系统 支持编写代码脚本进行逻辑交互支持(升级后的)
|
|
||||||
|
|
||||||
2. GLTF轻量化工具 对GLTF格式模型进行轻量化处理,轻量化率达50%以上(升级后的)
|
|
||||||
|
|
||||||
3. 数据组件 支持注册数据源,并驱动模型进行可视化展示
|
|
||||||
|
|
||||||
4. 分包打包 支持ktx2等格式纹理打包,拥有更快的打包速度和更小的包体积(升级后的)
|
|
||||||
|
|
||||||
5. 离线包 导出和加载场景离线包,支持项目二次开发
|
|
||||||
|
|
||||||
6. 扩展3D对象 热力图、U面板、动态路径、水池
|
|
||||||
|
|
||||||
|
|
||||||
```text
|
|
||||||
把 Astral项目的 Astral/3d/editor/src/views/editor/components/extraPane/resource/builtin/Expansion.vue
|
|
||||||
迁移到 TkAstral3D/packages/editor/src/views/editor/components/extraPane/resource/builtin 下
|
|
||||||
要保证扩展中所有相关功能正常
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ VITE_ENABLE_ANALYZE=true
|
|||||||
VITE_ENABLE_CONFIG_GENERATE=true
|
VITE_ENABLE_CONFIG_GENERATE=true
|
||||||
|
|
||||||
# 当前站点URL
|
# 当前站点URL
|
||||||
VITE_GLOB_ORIGIN=https://editor.astraljs.com/
|
VITE_GLOB_ORIGIN=https://tk.astraltwin.cn/
|
||||||
|
|
||||||
#ws接口
|
#ws接口
|
||||||
VITE_GLOB_SOCKET_URL=wss://editor.astraljs.com/socket
|
VITE_GLOB_SOCKET_URL=wss://tk.astraltwin.cn/api/sys/ws
|
||||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||||
import * as monaco from 'monaco-editor';
|
import * as monaco from 'monaco-editor';
|
||||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
@@ -74,10 +74,22 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.source,
|
||||||
|
value => {
|
||||||
|
if (!editor) return;
|
||||||
|
const currentValue = editor.getValue();
|
||||||
|
if (value !== currentValue) {
|
||||||
|
editor.setValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function initMonaco() {
|
async function initMonaco() {
|
||||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||||
noSemanticValidation: false,
|
noSemanticValidation: false,
|
||||||
noSyntaxValidation: false,
|
noSyntaxValidation: false,
|
||||||
|
diagnosticCodesToIgnore: [80002],
|
||||||
});
|
});
|
||||||
// 如果使用 Webpack 或其他打包工具,可以使用 importScripts
|
// 如果使用 Webpack 或其他打包工具,可以使用 importScripts
|
||||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {onMounted, ref} from "vue";
|
|||||||
import {Copy} from "@vicons/carbon";
|
import {Copy} from "@vicons/carbon";
|
||||||
import {NIcon, NTooltip} from "naive-ui";
|
import {NIcon, NTooltip} from "naive-ui";
|
||||||
import {t} from "@/language";
|
import {t} from "@/language";
|
||||||
import {App,Hooks,AddObjectCommand} from "@astral3d/engine";
|
import {App,Hooks,Loader,Utils,AddObjectCommand} from "@astral3d/engine";
|
||||||
|
|
||||||
const disabled = ref(true);
|
const disabled = ref(true);
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@ function handleClone() {
|
|||||||
//避免复制相机或场景
|
//避免复制相机或场景
|
||||||
if (object === null || object.parent === null) return;
|
if (object === null || object.parent === null) return;
|
||||||
|
|
||||||
|
if (Utils.isHtmlPanelObject(object)) {
|
||||||
|
const _json = object.toJSON() as any;
|
||||||
|
|
||||||
|
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
|
||||||
|
App.execute(new AddObjectCommand(newObject3D));
|
||||||
|
}).catch((e: Error) => window.$message?.error(e.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
object = object.clone();
|
object = object.clone();
|
||||||
|
|
||||||
App.execute(new AddObjectCommand(object));
|
App.execute(new AddObjectCommand(object));
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ChoroplethMap
|
ChoroplethMap
|
||||||
} from '@vicons/carbon';
|
} from '@vicons/carbon';
|
||||||
import {t} from "@/language";
|
import {t} from "@/language";
|
||||||
import {App,Hooks, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine";
|
import {App,Hooks,Loader,Utils, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine";
|
||||||
import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils";
|
import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils";
|
||||||
import {getMaterialName} from "@/utils/common/scenes";
|
import {getMaterialName} from "@/utils/common/scenes";
|
||||||
import EsContextmenu from "@/components/es/EsContextmenu.vue";
|
import EsContextmenu from "@/components/es/EsContextmenu.vue";
|
||||||
@@ -367,6 +367,15 @@ function handleContextmenuSelect(key: string) {
|
|||||||
if (parent !== null) App.execute(new RemoveObjectCommand(object));
|
if (parent !== null) App.execute(new RemoveObjectCommand(object));
|
||||||
break;
|
break;
|
||||||
case "clone":
|
case "clone":
|
||||||
|
if (Utils.isHtmlPanelObject(object)) {
|
||||||
|
const _json = object.toJSON() as any;
|
||||||
|
|
||||||
|
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
|
||||||
|
App.execute(new AddObjectCommand(newObject3D));
|
||||||
|
}).catch((e: Error) => window.$message?.error(e.message));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const _object = object.clone();
|
const _object = object.clone();
|
||||||
|
|
||||||
App.execute(new AddObjectCommand(_object));
|
App.execute(new AddObjectCommand(_object));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface DataSetPayload {
|
|||||||
type: "API" | "SQL" | "JSON" | string;
|
type: "API" | "SQL" | "JSON" | string;
|
||||||
method?: IDataSet.Item["method"];
|
method?: IDataSet.Item["method"];
|
||||||
api?: string;
|
api?: string;
|
||||||
dataSourceId?: string;
|
dataSourceId?: IDataSet.Item["id"];
|
||||||
sql?: string;
|
sql?: string;
|
||||||
json?: string;
|
json?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {request} from "@/http/request";
|
||||||
|
|
||||||
|
export interface DataSetGroupPayload {
|
||||||
|
id?: IDataSet.IGroup["id"];
|
||||||
|
name: string;
|
||||||
|
pid: IDataSet.IGroup["pid"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDataSetGroupTree() {
|
||||||
|
return request.get<IDataSet.IGroup[]>(`/data-set-group/tree`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCreateDataSetGroup(data: DataSetGroupPayload) {
|
||||||
|
return request.post(`/data-set-group`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateDataSetGroup(data: DataSetGroupPayload) {
|
||||||
|
return request.put(`/data-set-group`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteDataSetGroup(id: IDataSet.IGroup["id"]) {
|
||||||
|
return request.delete(`/data-set-group/${id}`, {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {request} from "@/http/request";
|
||||||
|
|
||||||
|
export interface DataSourcePayload {
|
||||||
|
id?: IDataSource.Item["id"];
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
connectionString: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDataSourceList() {
|
||||||
|
return request.get<IDataSource.Item[]>(`/data-source/list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCreateDataSource(data: DataSourcePayload) {
|
||||||
|
return request.post<IDataSource.Item>(`/data-source`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateDataSource(data: DataSourcePayload) {
|
||||||
|
return request.put<IDataSource.Item>(`/data-source`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteDataSource(id: IDataSource.Item["id"]) {
|
||||||
|
return request.delete(`/data-source/${id}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTestDataSource(data: DataSourcePayload) {
|
||||||
|
return request.post(`/data-source/test`, data);
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ async function main() {
|
|||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
if (import.meta.env.PROD){
|
if (import.meta.env.PROD){
|
||||||
DisableDevtool({
|
// DisableDevtool({
|
||||||
clearLog:true
|
// md5: "32981a13284db7a021131df49e6cd203",
|
||||||
});
|
// clearLog: true,
|
||||||
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { injectWasm } from "@/utils/wasm/inject";
|
|
||||||
|
|
||||||
export function clearBuffer() {
|
|
||||||
injectWasm({ wasmUrl: "/wasm/Astral3DEditor.wasm" }).then(() => {
|
|
||||||
// 加载完wasm后自动注册了清除缓存的函数调用
|
|
||||||
window.clearBuffer();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -4,20 +4,16 @@
|
|||||||
<SceneTree />
|
<SceneTree />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-tabs default-value="bim" animated type="line" justify-content="space-around" class="!h-60%"
|
<n-tabs default-value="cad" animated type="line" justify-content="space-around" class="!h-60%"
|
||||||
pane-wrapper-class="layout-assets-tab-pane-wrapper">
|
pane-wrapper-class="layout-assets-tab-pane-wrapper">
|
||||||
<n-tab-pane name="cad" tab="CAD" display-directive="show">
|
<n-tab-pane name="cad" tab="CAD" display-directive="show">
|
||||||
<CadLibrary />
|
<CadLibrary />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="bim" tab="BIM" display-directive="show">
|
|
||||||
<BIMLibrary />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BIMLibrary from "./assets/BIMLibrary.vue";
|
|
||||||
import SceneTree from "@/components/tree/SceneTree.vue";
|
import SceneTree from "@/components/tree/SceneTree.vue";
|
||||||
import CadLibrary from "./assets/CadLibrary.vue";
|
import CadLibrary from "./assets/CadLibrary.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div id="bim-library" class="h-full flex flex-col">
|
|
||||||
<n-alert type="info" :show-icon="false" :bordered="false" class="mb-2 mx-2">
|
|
||||||
<n-button quaternary round type="primary" @click="showHistoryModal = true">
|
|
||||||
{{ t("layout.sider.History") }}
|
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<n-button quaternary circle type="primary" @click="showBIMUpload = true">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon>
|
|
||||||
<CloudUpload/>
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</n-alert>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-0.3rem">
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-2">
|
|
||||||
<n-card size="small" hoverable v-for="item in objectList" :key="item.id" @dblclick="addToScene(item)"
|
|
||||||
draggable="true" @dragstart="dragStart($event, item)" @dragend="dragEnd">
|
|
||||||
<template #cover>
|
|
||||||
<n-spin :show="item.conversionStatus === 0">
|
|
||||||
<template #description>
|
|
||||||
正在轻量化...
|
|
||||||
</template>
|
|
||||||
<img :src="item.thumbnail ? item.thumbnail : '/static/images/placeholder/占位图.png'"
|
|
||||||
:alt="item.fileName" draggable="false">
|
|
||||||
<n-tag :color="{ color: '#F1C3CC', textColor: '#D03050' }" :bordered="false" size="small"
|
|
||||||
class="absolute top-33px w-full" v-if="item.conversionStatus === 2">
|
|
||||||
轻量化失败
|
|
||||||
<template #icon>
|
|
||||||
<n-icon>
|
|
||||||
<CloseCircleSharp/>
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
</n-tag>
|
|
||||||
</n-spin>
|
|
||||||
</template>
|
|
||||||
<n-tooltip placement="bottom" trigger="hover">
|
|
||||||
<template #trigger> {{ item.fileName }}</template>
|
|
||||||
<span> {{ item.fileName }} </span>
|
|
||||||
</n-tooltip>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center mt-0.5rem">
|
|
||||||
<n-pagination v-bind="paginationReactive"></n-pagination>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-modal v-model:show="showHistoryModal" class="!w-60vw" preset="dialog" display-directive="show"
|
|
||||||
:title="t('bim[\'BIM lightweight\']') + t('layout.sider.History')">
|
|
||||||
<n-data-table class="mt-20px" size="small" :loading="tableLoading" :columns="columns"
|
|
||||||
:data="objectList"></n-data-table>
|
|
||||||
<div class="flex justify-end mt-0.5rem">
|
|
||||||
<n-pagination v-bind="paginationReactive"></n-pagination>
|
|
||||||
</div>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<!-- BIM文件上传 -->
|
|
||||||
<BimUploadDialog v-model:show="showBIMUpload" ref="uploadDialogRef"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref, reactive, onMounted, h, onBeforeUnmount} from "vue";
|
|
||||||
import type {DataTableColumns} from 'naive-ui';
|
|
||||||
import {NButton, NTag, NIcon} from 'naive-ui'
|
|
||||||
import {CloudUpload, Reload, CheckmarkCircle, CloseOutline, CloseCircleSharp} from '@vicons/ionicons5';
|
|
||||||
import {t} from "@/language";
|
|
||||||
import {useDragStore} from "@/store/modules/drag";
|
|
||||||
import {fetchGetBim2GltfList} from "@/http/api/bim";
|
|
||||||
import {Bim2GltfWsData, WebSocketMessage} from "~/network";
|
|
||||||
import {Loader,Hooks} from "@astral3d/engine";
|
|
||||||
import {filterSize} from "@/utils/common/file";
|
|
||||||
import {onWebSocket, offWebSocket} from "@/hooks/useWebSocket";
|
|
||||||
import {useWebsocketStore} from "@/store/modules/websocket";
|
|
||||||
import {dateTimeFormat} from "@/utils/common/dateTime";
|
|
||||||
import BimUploadDialog from "./bimLibrary/BimUploadDialog.vue";
|
|
||||||
|
|
||||||
const websocketStore = useWebsocketStore();
|
|
||||||
|
|
||||||
let objectList = ref<IBIMData[]>([]);
|
|
||||||
const showHistoryModal = ref(false);
|
|
||||||
const tableLoading = ref(false);
|
|
||||||
const columns: DataTableColumns<IBIMData> = [
|
|
||||||
{
|
|
||||||
title: '文件名',
|
|
||||||
key: 'fileName'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'bim文件体积',
|
|
||||||
key: 'bimFileSize',
|
|
||||||
render(row) {
|
|
||||||
return filterSize(row.bimFileSize);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'gltf文件体积',
|
|
||||||
key: 'gltfFileSize',
|
|
||||||
render(row) {
|
|
||||||
return filterSize(row.gltfFileSize);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '转换时长',
|
|
||||||
key: 'conversionDuration',
|
|
||||||
render(row) {
|
|
||||||
return row.conversionDuration.toFixed(2) + "s";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'conversionStatus',
|
|
||||||
render(row) {
|
|
||||||
return h(
|
|
||||||
NTag,
|
|
||||||
{
|
|
||||||
bordered: false,
|
|
||||||
type: row.conversionStatus === 0 ? "warning" : row.conversionStatus === 1 ? "success" : "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => row.conversionStatus === 0 ? "转换中" : row.conversionStatus === 1 ? "成功" : "失败",
|
|
||||||
icon: () => h(
|
|
||||||
NIcon, {
|
|
||||||
component: row.conversionStatus === 0 ? Reload : row.conversionStatus === 1 ? CheckmarkCircle : CloseOutline
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
render(row) {
|
|
||||||
if (row.conversionStatus !== 1) return "";
|
|
||||||
return h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: 'small',
|
|
||||||
onClick: () => addToScene(row)
|
|
||||||
},
|
|
||||||
{default: () => t("other.Load")}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
let paginationReactive = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
pageCount: 1,
|
|
||||||
"on-update:page": (page: number) => {
|
|
||||||
paginationReactive.page = page;
|
|
||||||
getBim2GltfList();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const showBIMUpload = ref(false);
|
|
||||||
const uploadDialogRef = ref();
|
|
||||||
|
|
||||||
// 获取BIM转换列表
|
|
||||||
async function getBim2GltfList() {
|
|
||||||
const res = await fetchGetBim2GltfList({
|
|
||||||
offset: (paginationReactive.page - 1) * paginationReactive.pageSize,
|
|
||||||
limit: paginationReactive.pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
objectList.value = res.data?.items || [];
|
|
||||||
paginationReactive.pageCount = res.data?.pages || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addToScene(item) {
|
|
||||||
showHistoryModal.value = false;
|
|
||||||
|
|
||||||
let notice = window.$notification.info({
|
|
||||||
title: window.$t("scene['Get the scene data']") + "...",
|
|
||||||
content: window.$t("other.Loading") + "...",
|
|
||||||
closable: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 从gltfFilePath字段去服务器获取gltf文件
|
|
||||||
fetch(`file/static/${item.gltfFilePath}`)
|
|
||||||
.then(res => res.blob())
|
|
||||||
.then(data => {
|
|
||||||
const file = new File([data as Blob], `${item.fileName}.glb`, {type: 'model/gltf-binary'});
|
|
||||||
|
|
||||||
notice.content = window.$t("scene['Parsing to editor']");
|
|
||||||
|
|
||||||
Loader.loadFiles([file], undefined).finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
notice.destroy();
|
|
||||||
Hooks.useDispatchSignal("sceneGraphChanged");
|
|
||||||
}, 800)
|
|
||||||
Hooks.useDispatchSignal("sceneGraphChanged");
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
notice.content = window.$t("scene['Failed to get scene data']");
|
|
||||||
setTimeout(() => {
|
|
||||||
notice.destroy();
|
|
||||||
}, 500)
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始拖拽事件
|
|
||||||
const dragStore = useDragStore();
|
|
||||||
|
|
||||||
function dragStart(_, item) {
|
|
||||||
if (item.conversionStatus !== 1) return;
|
|
||||||
dragStore.setData(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragEnd() {
|
|
||||||
if (dragStore.getActionTarget !== "addToScene") return;
|
|
||||||
|
|
||||||
const data = dragStore.getData
|
|
||||||
if (data.conversionStatus !== 1) return;
|
|
||||||
|
|
||||||
addToScene(data);
|
|
||||||
|
|
||||||
dragStore.setActionTarget("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// websocket监听 bim2gltf 消息
|
|
||||||
function Bim2GltfWsHandle(data: WebSocketMessage<Bim2GltfWsData>) {
|
|
||||||
if (data.type === "bim2gltf") {
|
|
||||||
// 判断订阅者,以确定是自己还是别人轻量化的结果
|
|
||||||
if (data.subscriber === websocketStore.uname) {
|
|
||||||
let wsNotice = uploadDialogRef.value?.getNotice();
|
|
||||||
// 如果是自己的轻量化结果,使用用户登录结果作为uname,则用户可能关闭页面再打开时接收到消息,应继续显示
|
|
||||||
if (!wsNotice) {
|
|
||||||
wsNotice = window.$notification.info({
|
|
||||||
title: t("bim['BIM lightweight']"),
|
|
||||||
content: "",
|
|
||||||
closable: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.conversionStatus === "progress") {
|
|
||||||
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
|
|
||||||
} else if (data.data.conversionStatus === "completed") {
|
|
||||||
wsNotice.content = `${t("bim['BIM lightweight completed']")},${t("bim.In")} ${data.data.item.conversionDuration} ${t("bim.seconds")}`;
|
|
||||||
setTimeout(() => {
|
|
||||||
wsNotice?.destroy();
|
|
||||||
window.$dialog.info({
|
|
||||||
title: t("bim['BIM lightweight completed']"),
|
|
||||||
content: t("bim['Whether to load the BIM model into the scene?']"),
|
|
||||||
positiveText: window.$t('other.Load'),
|
|
||||||
negativeText: window.$t('other.Cancel'),
|
|
||||||
onPositiveClick: () => {
|
|
||||||
addToScene(data.data.item);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, 800)
|
|
||||||
getBim2GltfList();
|
|
||||||
} else if (data.data.conversionStatus === "failed") {
|
|
||||||
wsNotice.content = t("bim['BIM lightweight failed']");
|
|
||||||
setTimeout(() => {
|
|
||||||
wsNotice?.destroy();
|
|
||||||
}, 1500)
|
|
||||||
getBim2GltfList();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (data.data.conversionStatus !== "completed") return;
|
|
||||||
|
|
||||||
// 别人的轻量化结果
|
|
||||||
const n = window.$notification.info({
|
|
||||||
title: t("bim['BIM lightweight']"),
|
|
||||||
content: t("bim['New lightweight BIM model received, do you want to view it?']"),
|
|
||||||
duration: 5000,
|
|
||||||
closable: true,
|
|
||||||
meta: dateTimeFormat("yyyy-MM-dd HH:mm:ss"),
|
|
||||||
action: () =>
|
|
||||||
h(NButton, {
|
|
||||||
text: true,
|
|
||||||
type: 'primary',
|
|
||||||
onClick: () => {
|
|
||||||
addToScene(data.data.item);
|
|
||||||
n.destroy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => t("other.Load")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getBim2GltfList();
|
|
||||||
|
|
||||||
// 注册websocket消息监听
|
|
||||||
onWebSocket(Bim2GltfWsHandle);
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
offWebSocket(Bim2GltfWsHandle)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
#bim-library {
|
|
||||||
overflow-x: hidden;
|
|
||||||
|
|
||||||
.n-alert {
|
|
||||||
:deep(.n-alert-body) {
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.n-card {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
:deep(.n-card-cover) {
|
|
||||||
img {
|
|
||||||
height: 89px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-card__content) {
|
|
||||||
padding: 3px 5px;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal :show="show" @update:show="(b) => emits('update:show',b)" class="!w-500px" preset="card" :title="t('bim.BIM lightweight')">
|
|
||||||
<n-form label-placement="left" :model="BIMModel" :rules="BIMRules"
|
|
||||||
label-width="100px" label-align="right" ref="formRef"
|
|
||||||
require-mark-placement="right-hanging">
|
|
||||||
<!-- <n-form-item :label="t('bim[\'File name\']')" path="fileName">-->
|
|
||||||
<!-- <n-input v-model:value="BIMModel.fileName"-->
|
|
||||||
<!-- :placeholder="t('bim[\'Please enter the BIM file name\']')"/>-->
|
|
||||||
<!-- </n-form-item>-->
|
|
||||||
<n-form-item :label="t('bim.Thumbnail')">
|
|
||||||
<n-upload :default-upload="false" list-type="image-card" :max="1"
|
|
||||||
@change="thumbnailChange"/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('bim[\'BIM file\']')" path="bimFile">
|
|
||||||
<n-upload ref="uploadBIMRef" :default-upload="false" directory-dnd :max="1"
|
|
||||||
:accept="'.' + NEED_CONVERT_BIM_MODEL.join(',.')" @change="bimChange">
|
|
||||||
<n-upload-dragger>
|
|
||||||
<div>
|
|
||||||
<n-icon size="48" :depth="3">
|
|
||||||
<ArchiveOutline/>
|
|
||||||
</n-icon>
|
|
||||||
</div>
|
|
||||||
<n-text style="font-size: 14px">
|
|
||||||
{{
|
|
||||||
t("bim['Click or drag the file to this area.Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`
|
|
||||||
}}
|
|
||||||
</n-text>
|
|
||||||
</n-upload-dragger>
|
|
||||||
</n-upload>
|
|
||||||
</n-form-item>
|
|
||||||
<n-collapse>
|
|
||||||
<template #arrow>
|
|
||||||
<n-icon>
|
|
||||||
<CaretForwardOutline />
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-collapse-item :title="t('bim[\'Conversion configuration\']')">
|
|
||||||
<!-- 极致轻量化 -->
|
|
||||||
<n-form-item :label="t('bim[\'Extreme lightweight\']')">
|
|
||||||
<n-switch v-model:value="BIMModel.options.Optimize" @change="handleOptimizeChange" />
|
|
||||||
</n-form-item>
|
|
||||||
<!-- 导出属性 -->
|
|
||||||
<n-form-item :label="t('bim[\'Export Property\']')">
|
|
||||||
<n-switch v-model:value="BIMModel.options.ExportProperty" :disabled="exportPropertyDisabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<!-- 转换视图 -->
|
|
||||||
<n-form-item :label="t('bim[\'Conversion view\']')">
|
|
||||||
<n-radio-group v-model:value="BIMModel.options.View" name="View">
|
|
||||||
<n-radio-button value="Default">默认3D视图</n-radio-button>
|
|
||||||
<n-radio-button value="Name">按名称</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
<!-- 视图名称 -->
|
|
||||||
<n-form-item :label="t('bim[\'View name\']')" v-if="BIMModel.options.View === 'Name'">
|
|
||||||
<n-input v-model:value="BIMModel.options.ViewName" :placeholder="t('bim[\'Please enter the conversion view name\']')"/>
|
|
||||||
</n-form-item>
|
|
||||||
<!-- 视觉样式 -->
|
|
||||||
<n-form-item :label="t('bim[\'Display style\']')">
|
|
||||||
<n-radio-group v-model:value="BIMModel.options.DisplayStyle" name="DisplayStyle">
|
|
||||||
<n-radio-button value="Colour">{{t("bim.Colour")}}</n-radio-button>
|
|
||||||
<n-radio-button value="Realistic">{{t("bim.Realistic")}}</n-radio-button>
|
|
||||||
<n-radio-button value="ViewDefault">{{t("bim['View default']")}}</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
<!-- 坐标参考 -->
|
|
||||||
<n-form-item :label="t('bim[\'Coordinate reference\']')">
|
|
||||||
<n-radio-group v-model:value="BIMModel.options.CoordinateReference" name="CoordinateReference">
|
|
||||||
<n-radio-button value="Origin">{{t("bim.Origin")}}</n-radio-button>
|
|
||||||
<n-radio-button value="ProjectBasePoint">{{t("bim['Project base point']")}}</n-radio-button>
|
|
||||||
<n-radio-button value="MeasuringPoint">{{t("bim['Measuring point']")}}</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
</n-collapse-item>
|
|
||||||
</n-collapse>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<n-button round type="primary" @click="submit">{{ t("bim['Upload and lightweight']") }}</n-button>
|
|
||||||
</div>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {reactive, ref} from "vue";
|
|
||||||
import {NotificationReactive} from "naive-ui";
|
|
||||||
import {t} from "@/language";
|
|
||||||
import {ArchiveOutline, CaretForwardOutline} from "@vicons/ionicons5";
|
|
||||||
import {fetchUpload} from "@/http/api/sys";
|
|
||||||
import {fetchAddBim2Gltf, fetchUploadRvt} from "@/http/api/bim";
|
|
||||||
import {NEED_CONVERT_BIM_MODEL} from "@/utils/common/constant";
|
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
|
||||||
show:boolean
|
|
||||||
}>(),{
|
|
||||||
show:false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emits = defineEmits(["update:show"]);
|
|
||||||
|
|
||||||
const formRef = ref();
|
|
||||||
const uploadBIMRef = ref();
|
|
||||||
const exportPropertyDisabled = ref(false);
|
|
||||||
|
|
||||||
/* bim文件上传转换 */
|
|
||||||
const BIMModel = reactive<{
|
|
||||||
fileName: string,
|
|
||||||
thumbnail: File | null,
|
|
||||||
bimFile: File | null,
|
|
||||||
options:{
|
|
||||||
// 极致压缩率(会执行实例化网格,破坏模型结构)
|
|
||||||
Optimize:boolean,
|
|
||||||
// 是否导出属性
|
|
||||||
ExportProperty:boolean,
|
|
||||||
// 转换视图 - Default:默认3D视图 | Name:按传入名称查找视图
|
|
||||||
View:string,
|
|
||||||
// 转换视图名称 - View = "Name"时输入有效
|
|
||||||
ViewName:string,
|
|
||||||
// 转换视觉样式 - Colour:着色模式 | Realistic-真实模式 | ViewDefault-视图默认
|
|
||||||
DisplayStyle:string,
|
|
||||||
// 坐标参考 - Origin:默认原点 | ProjectBasePoint:项目基点 | MeasuringPoint:测量点
|
|
||||||
CoordinateReference:string
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
fileName: "",
|
|
||||||
thumbnail: null,
|
|
||||||
bimFile: null,
|
|
||||||
options:{
|
|
||||||
Optimize:false,
|
|
||||||
ExportProperty:true,
|
|
||||||
View:"Default",
|
|
||||||
ViewName:"",
|
|
||||||
DisplayStyle:"Colour",
|
|
||||||
CoordinateReference:"Origin"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const BIMRules = {
|
|
||||||
bimFile: {
|
|
||||||
required: true,
|
|
||||||
trigger: 'change',
|
|
||||||
validator: (_, value) => {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
// 判断value是否是File类型
|
|
||||||
if (value === '' || !(value instanceof File)) {
|
|
||||||
reject(Error(t("bim['Please upload the BIM file']")))
|
|
||||||
} else {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预览图变化
|
|
||||||
function thumbnailChange({file}) {
|
|
||||||
if (file.status === "removed") {
|
|
||||||
BIMModel.thumbnail = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BIMModel.thumbnail = file.file as File;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bim文件选择变化
|
|
||||||
function bimChange({file}) {
|
|
||||||
if (file.status === "removed") {
|
|
||||||
BIMModel.bimFile = null;
|
|
||||||
formRef.value?.validate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!NEED_CONVERT_BIM_MODEL.includes(file.name.split(".").at(-1).toLowerCase())) {
|
|
||||||
window.$message?.error(t("prompt['This format is not supported, please upload again! Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`);
|
|
||||||
uploadBIMRef.value.clear();
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
BIMModel.bimFile = file.file as File;
|
|
||||||
BIMModel.fileName = BIMModel.bimFile.name;
|
|
||||||
formRef.value?.validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 极致轻量化选项变化(导出属性选项跟随变化)
|
|
||||||
function handleOptimizeChange() {
|
|
||||||
if(BIMModel.options.Optimize){
|
|
||||||
BIMModel.options.ExportProperty = false;
|
|
||||||
exportPropertyDisabled.value = true;
|
|
||||||
}else{
|
|
||||||
exportPropertyDisabled.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ws 监听使用的notice
|
|
||||||
let wsNotice: null | NotificationReactive = null;
|
|
||||||
const getNotice = () => wsNotice;
|
|
||||||
// 提交
|
|
||||||
function submit(e) {
|
|
||||||
if (import.meta.env.PROD) {
|
|
||||||
window.$message?.error(window.$t("prompt['Disable this function in the demonstration environment!']"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
formRef.value?.validate(async (errors) => {
|
|
||||||
if (!errors) {
|
|
||||||
emits("update:show",false);
|
|
||||||
|
|
||||||
wsNotice = window.$notification.info({
|
|
||||||
title: t("bim['BIM lightweight']"),
|
|
||||||
content: t("prompt.Uploading") + "...",
|
|
||||||
closable: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 1. 上传缩略图
|
|
||||||
let thumbnail = "";
|
|
||||||
if (BIMModel.thumbnail) {
|
|
||||||
const res = await fetchUpload({
|
|
||||||
file: BIMModel.thumbnail,
|
|
||||||
biz: "upload/bim/thumbnail"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.data === null) {
|
|
||||||
window.$message?.error(t("bim['Failed to upload thumbnail']"));
|
|
||||||
} else {
|
|
||||||
thumbnail = res.data as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 上传bim文件
|
|
||||||
const bimRes = await fetchUploadRvt({
|
|
||||||
file: BIMModel.bimFile,
|
|
||||||
});
|
|
||||||
if (bimRes.data === null) {
|
|
||||||
window.$message?.error(t("bim['Failed to upload BIM file']"));
|
|
||||||
wsNotice.content = `${t("bim['Failed to upload BIM file']")},${t("prompt['Please try again later!']")}`;
|
|
||||||
setTimeout(() => {
|
|
||||||
wsNotice?.destroy();
|
|
||||||
}, 1000)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//3 启动Revit轻量化服务
|
|
||||||
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
|
|
||||||
await fetchAddBim2Gltf({
|
|
||||||
bimFilePath: bimRes.data,
|
|
||||||
bimFileSize: (BIMModel.bimFile as File).size,
|
|
||||||
fileName: BIMModel.fileName,
|
|
||||||
fileSourceIp: "",
|
|
||||||
thumbnail,
|
|
||||||
conversionStatus: 0,
|
|
||||||
// 转换配置
|
|
||||||
options:BIMModel.options
|
|
||||||
})
|
|
||||||
|
|
||||||
// reset
|
|
||||||
BIMModel.fileName = "";
|
|
||||||
BIMModel.thumbnail = null;
|
|
||||||
BIMModel.bimFile = null;
|
|
||||||
BIMModel.options = {
|
|
||||||
Optimize:false,
|
|
||||||
ExportProperty:true,
|
|
||||||
View:"Default",
|
|
||||||
ViewName:"",
|
|
||||||
DisplayStyle:"Colour",
|
|
||||||
CoordinateReference:"Origin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({getNotice})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.n-form-item{
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -29,6 +29,22 @@ onBeforeUnmount(() => {
|
|||||||
Hooks.useRemoveSignal("objectSelected", updateUI);
|
Hooks.useRemoveSignal("objectSelected", updateUI);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ensureHtmlPanelOptions(object: any) {
|
||||||
|
const options = object?.options || {};
|
||||||
|
const codes = Array.isArray(options.codes) ? options.codes : [];
|
||||||
|
const isSingleHtml = typeof options.isSingleHtml === 'boolean' ? options.isSingleHtml : true;
|
||||||
|
const isSprite = typeof options.isSprite === 'boolean' ? options.isSprite : !!object?.isHtmlSprite;
|
||||||
|
|
||||||
|
object.options = {
|
||||||
|
...options,
|
||||||
|
codes,
|
||||||
|
isSingleHtml,
|
||||||
|
isSprite
|
||||||
|
};
|
||||||
|
|
||||||
|
return object.options;
|
||||||
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
const object = App.selected;
|
const object = App.selected;
|
||||||
if(!object) return;
|
if(!object) return;
|
||||||
@@ -39,7 +55,8 @@ function updateUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.type = object.isHtmlPanel? 'panel' :'sprite';
|
data.type = object.isHtmlPanel? 'panel' :'sprite';
|
||||||
data.codes = object.options.codes;
|
const options = ensureHtmlPanelOptions(object);
|
||||||
|
data.codes = options.codes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(method: string){
|
function update(method: string){
|
||||||
@@ -53,8 +70,9 @@ function update(method: string){
|
|||||||
if(data.type === 'panel' && object.isHtmlPanel) return;
|
if(data.type === 'panel' && object.isHtmlPanel) return;
|
||||||
if(data.type === 'sprite' && object.isHtmlSprite) return;
|
if(data.type === 'sprite' && object.isHtmlSprite) return;
|
||||||
|
|
||||||
const _json = object.toJSON();
|
const _json = object.toJSON() as any;
|
||||||
_json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel';
|
_json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel';
|
||||||
|
_json.object.options = _json.object.options || {};
|
||||||
_json.object.options.isSprite = !_json.object.options.isSprite;
|
_json.object.options.isSprite = !_json.object.options.isSprite;
|
||||||
|
|
||||||
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
|
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
|
||||||
@@ -73,9 +91,11 @@ function update(method: string){
|
|||||||
|
|
||||||
_code.content = editorCode.code;
|
_code.content = editorCode.code;
|
||||||
|
|
||||||
|
const options = ensureHtmlPanelOptions(object);
|
||||||
|
|
||||||
const htmlPanelOption = {
|
const htmlPanelOption = {
|
||||||
isSprite: object.options.isSprite,
|
isSprite: options.isSprite,
|
||||||
isSingleHtml:object.options.isSingleHtml,
|
isSingleHtml: options.isSingleHtml,
|
||||||
codes: data.codes
|
codes: data.codes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,19 +155,26 @@ function updateFileList(fList: UploadFileInfo[]) {
|
|||||||
isSprite: data.type ==='sprite',
|
isSprite: data.type ==='sprite',
|
||||||
fileName: file.name
|
fileName: file.name
|
||||||
}).then(htmlPlaneObj => {
|
}).then(htmlPlaneObj => {
|
||||||
data.codes = htmlPlaneObj.options.codes;
|
const options = ensureHtmlPanelOptions(object);
|
||||||
|
|
||||||
const _json = object.toJSON();
|
options.codes = htmlPlaneObj.options.codes;
|
||||||
Loader.objectLoader.copyAttrByData(htmlPlaneObj, _json.object)
|
options.isSingleHtml = htmlPlaneObj.options.isSingleHtml;
|
||||||
|
|
||||||
object.parent?.add(htmlPlaneObj);
|
data.codes = options.codes;
|
||||||
|
|
||||||
object.parent?.remove(object);
|
// Keep object identity (id/uuid) so scene tree and context menu references stay valid.
|
||||||
|
while (object.element.firstChild) {
|
||||||
|
object.element.removeChild(object.element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
App.selected = htmlPlaneObj;
|
while (htmlPlaneObj.element.firstChild) {
|
||||||
|
object.element.appendChild(htmlPlaneObj.element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
Hooks.useDispatchSignal("objectSelected", htmlPlaneObj);
|
App.selected = object;
|
||||||
}).catch((e:Error) => window.$message?.error(e.message));
|
Hooks.useDispatchSignal("objectSelected", object);
|
||||||
|
}).catch((e:Error) => window.$message?.error(e.message))
|
||||||
|
.finally(() => URL.revokeObjectURL(tempURL));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCanEdit(name: string) {
|
function getCanEdit(name: string) {
|
||||||
|
|||||||
@@ -121,15 +121,15 @@ const updateUI = Utils.throttle(function(object) {
|
|||||||
objectData.type = object.type;
|
objectData.type = object.type;
|
||||||
objectData.uuid = object.uuid;
|
objectData.uuid = object.uuid;
|
||||||
objectData.name = object.name;
|
objectData.name = object.name;
|
||||||
objectData.position.x = Number(object.position.x.toFixed(3));
|
objectData.position.x = Number(object.position.x.toFixed(6));
|
||||||
objectData.position.y = Number(object.position.y.toFixed(3));
|
objectData.position.y = Number(object.position.y.toFixed(6));
|
||||||
objectData.position.z = Number(object.position.z.toFixed(3));
|
objectData.position.z = Number(object.position.z.toFixed(6));
|
||||||
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(3));
|
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(6));
|
||||||
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(3));
|
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(6));
|
||||||
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(3));
|
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(6));
|
||||||
objectData.scale.x = Number(object.scale.x.toFixed(3));
|
objectData.scale.x = Number(object.scale.x.toFixed(6));
|
||||||
objectData.scale.y = Number(object.scale.y.toFixed(3));
|
objectData.scale.y = Number(object.scale.y.toFixed(6));
|
||||||
objectData.scale.z = Number(object.scale.z.toFixed(3));
|
objectData.scale.z = Number(object.scale.z.toFixed(6));
|
||||||
|
|
||||||
if (object.fov !== undefined) {
|
if (object.fov !== undefined) {
|
||||||
objectData.fov = object.fov;
|
objectData.fov = object.fov;
|
||||||
@@ -225,19 +225,19 @@ const update = (method: string) => {
|
|||||||
},
|
},
|
||||||
position: () => {
|
position: () => {
|
||||||
const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z);
|
const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z);
|
||||||
if (object.position.distanceTo(newPosition) >= 0.01) {
|
if (object.position.distanceTo(newPosition) >= 0.0001) {
|
||||||
App.execute(new SetPositionCommand(object, newPosition));
|
App.execute(new SetPositionCommand(object, newPosition));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rotation: () => {
|
rotation: () => {
|
||||||
const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD);
|
const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD);
|
||||||
if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.01) {
|
if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.0001) {
|
||||||
App.execute(new SetRotationCommand(object, newRotation, undefined));
|
App.execute(new SetRotationCommand(object, newRotation, undefined));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scale: () => {
|
scale: () => {
|
||||||
const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z);
|
const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z);
|
||||||
if (object.scale.distanceTo(newScale) >= 0.01) {
|
if (object.scale.distanceTo(newScale) >= 0.0001) {
|
||||||
App.execute(new SetScaleCommand(object, newScale, undefined));
|
App.execute(new SetScaleCommand(object, newScale, undefined));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -433,11 +433,11 @@ const handleUserDataClick = () => {
|
|||||||
<div class="sider-scene-attr-item">
|
<div class="sider-scene-attr-item">
|
||||||
<EsKeyFrame :label="t('layout.sider.object.position')" attr="position" />
|
<EsKeyFrame :label="t('layout.sider.object.position')" attr="position" />
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="3" :step="1"
|
<EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="6" :step="1"
|
||||||
@change="update('position')" />
|
@change="update('position')" />
|
||||||
<EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="3" :step="1"
|
<EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="6" :step="1"
|
||||||
@change="update('position')" />
|
@change="update('position')" />
|
||||||
<EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="3" :step="1"
|
<EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="6" :step="1"
|
||||||
@change="update('position')" />
|
@change="update('position')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,11 +445,11 @@ const handleUserDataClick = () => {
|
|||||||
<div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation">
|
<div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation">
|
||||||
<EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" />
|
<EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" />
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="2" :step="1" :show-button="false"
|
<EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="6" :step="1" :show-button="false"
|
||||||
@change="update('rotation')" unit="°" />
|
@change="update('rotation')" unit="°" />
|
||||||
<EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="2" :step="1" :show-button="false"
|
<EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="6" :step="1" :show-button="false"
|
||||||
@change="update('rotation')" unit="°" />
|
@change="update('rotation')" unit="°" />
|
||||||
<EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="2" :step="1" :show-button="false"
|
<EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="6" :step="1" :show-button="false"
|
||||||
@change="update('rotation')" unit="°" />
|
@change="update('rotation')" unit="°" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-card v-if="visible" class="absolute top-40px right-1 max-w-300px" content-style="padding: 5px 10px;">
|
|
||||||
<n-collapse accordion default-expanded-names="bim">
|
|
||||||
<template #arrow="{ collapsed }">
|
|
||||||
<n-icon v-if="collapsed">
|
|
||||||
<PlanetOutline/>
|
|
||||||
</n-icon>
|
|
||||||
<n-icon v-else>
|
|
||||||
<SunnyOutline/>
|
|
||||||
</n-icon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-collapse-item title=" BIM 构件信息" name="bim">
|
|
||||||
<div class="max-h-360px overflow-y-auto">
|
|
||||||
<n-descriptions v-for="(item, index) in info.parameters" :key="index" label-placement="left"
|
|
||||||
bordered size="small" :column="1">
|
|
||||||
<template #header>
|
|
||||||
<p class="py-2">{{ item.GroupName }}</p>
|
|
||||||
</template>
|
|
||||||
<n-descriptions-item v-for="(it, i) in item.Parameters" :key="i + it.name" :label="it.name">
|
|
||||||
{{ it.value }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
</div>
|
|
||||||
</n-collapse-item>
|
|
||||||
</n-collapse>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {PlanetOutline, SunnyOutline} from "@vicons/ionicons5";
|
|
||||||
import {ref, onBeforeUnmount, onMounted} from "vue";
|
|
||||||
import {Hooks} from "@astral3d/engine";
|
|
||||||
|
|
||||||
const visible = ref(false);
|
|
||||||
const info = ref({
|
|
||||||
parameters: [{GroupName: "", Parameters: [{name: "", value: ""}]}]
|
|
||||||
})
|
|
||||||
|
|
||||||
function objectSelected(object) {
|
|
||||||
if (!object) {
|
|
||||||
visible.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!object.userData.BIM) {
|
|
||||||
if (!object.parent || !object.parent.userData.BIM) {
|
|
||||||
visible.value = false;
|
|
||||||
} else {
|
|
||||||
visible.value = true;
|
|
||||||
info.value = object.parent.userData.BIM;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
visible.value = true;
|
|
||||||
info.value = object.userData.BIM;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
Hooks.useAddSignal("objectSelected", objectSelected);
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
Hooks.useRemoveSignal("objectSelected", objectSelected);
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -6,9 +6,6 @@
|
|||||||
<ViewportInfo/>
|
<ViewportInfo/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RVT BIM 构件信息悬浮框 -->
|
|
||||||
<BIMProperties/>
|
|
||||||
|
|
||||||
<!-- IFC BIM 构件信息悬浮框 -->
|
<!-- IFC BIM 构件信息悬浮框 -->
|
||||||
<IFCProperties/>
|
<IFCProperties/>
|
||||||
|
|
||||||
@@ -18,15 +15,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue';
|
import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue';
|
||||||
import {App,Viewer,Hooks} from "@astral3d/engine";
|
import {App,Viewer,Hooks,WaterPool} from "@astral3d/engine";
|
||||||
import Toolbar from "./Toolbar.vue";
|
import Toolbar from "./Toolbar.vue";
|
||||||
import PathDrawingOverlay from "./PathDrawingOverlay.vue";
|
import PathDrawingOverlay from "./PathDrawingOverlay.vue";
|
||||||
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
|
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
|
||||||
import {usePluginStore} from "@/store/modules/plugin";
|
import {usePluginStore} from "@/store/modules/plugin";
|
||||||
import {installBuiltinPlugin} from "@/plugin";
|
import {installBuiltinPlugin} from "@/plugin";
|
||||||
import { clearBuffer } from "@/utils/wasm/optimize";
|
|
||||||
import ViewportInfo from "./ViewportInfo.vue";
|
import ViewportInfo from "./ViewportInfo.vue";
|
||||||
import BIMProperties from "./BIMProperties.vue";
|
|
||||||
import IFCProperties from "./IFCProperties.vue";
|
import IFCProperties from "./IFCProperties.vue";
|
||||||
|
|
||||||
const globalStore = useGlobalConfigStore();
|
const globalStore = useGlobalConfigStore();
|
||||||
@@ -34,6 +29,43 @@ const pluginStore = usePluginStore();
|
|||||||
|
|
||||||
const viewportRef = ref();
|
const viewportRef = ref();
|
||||||
|
|
||||||
|
function bootstrapWaterPoolWarmup(viewer: Viewer) {
|
||||||
|
const warmupPool = new WaterPool({
|
||||||
|
sky: viewer.scene.environment,
|
||||||
|
name: '__water_pool_warmup__',
|
||||||
|
type: 'cylinder',
|
||||||
|
light: [0.7, 1, -0.3],
|
||||||
|
diameter: 5,
|
||||||
|
height: 5,
|
||||||
|
wallMode: 'none',
|
||||||
|
wallOpacity: 0,
|
||||||
|
useSceneRefraction: 1,
|
||||||
|
surfaceTransmittance: 0.6,
|
||||||
|
normalStrength: 0.5,
|
||||||
|
refractionStrength: 0.035,
|
||||||
|
});
|
||||||
|
|
||||||
|
warmupPool.visible = false;
|
||||||
|
|
||||||
|
// Use scene.add/remove directly to avoid objectAdded/objectRemoved signals and scene tree refresh.
|
||||||
|
viewer.scene.add(warmupPool);
|
||||||
|
|
||||||
|
const removeWarmupPool = () => {
|
||||||
|
if (warmupPool.parent) {
|
||||||
|
warmupPool.parent.remove(warmupPool);
|
||||||
|
}
|
||||||
|
warmupPool.dispose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = (warmupPool as any).loaded;
|
||||||
|
if (loaded && typeof loaded.then === 'function') {
|
||||||
|
loaded.finally(removeWarmupPool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWarmupPool();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
App.setConfig({
|
App.setConfig({
|
||||||
theme: globalStore.theme.replace("Theme", ""),
|
theme: globalStore.theme.replace("Theme", ""),
|
||||||
@@ -52,16 +84,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
bootstrapWaterPoolWarmup(window.viewer);
|
||||||
|
|
||||||
// 添加astral engine内置插件并监听插件注册
|
// 添加astral engine内置插件并监听插件注册
|
||||||
pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values()));
|
pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values()));
|
||||||
Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin);
|
Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin);
|
||||||
Hooks.useAddSignal("pluginUninstall",pluginStore.removePlugin);
|
Hooks.useAddSignal("pluginUninstall",pluginStore.removePlugin);
|
||||||
|
|
||||||
// 注册astral editor内置插件
|
// 注册astral editor内置插件
|
||||||
installBuiltinPlugin(window.viewer);
|
installBuiltinPlugin(window.viewer);
|
||||||
|
|
||||||
// 清理wasm缓存
|
|
||||||
clearBuffer();
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</n-split> -->
|
</n-split> -->
|
||||||
|
|
||||||
<n-flex class="h-full w-full">
|
<n-flex class="h-full w-full">
|
||||||
<DataSetGroup class="w-50 min-w-300px max-w-500px" />
|
<DataSetGroup class="w-50 min-w-300px max-w-500px" @select="handleGroupSelect" />
|
||||||
|
|
||||||
<n-divider vertical class="!h-full" />
|
<n-divider vertical class="!h-full" />
|
||||||
|
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
|
|
||||||
<n-button type="primary" @click="showDataSetModal = true">{{ t('home.Add data set') }}</n-button>
|
<n-button type="primary" @click="handleAddDataSet">{{ t('home.Add data set') }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
<n-data-table ref="tableRef" :columns="dataSetColumns" :data="dataSets" :pagination="pagination" />
|
<n-data-table :columns="dataSetColumns" :data="dataSets" :pagination="pagination" :loading="tableLoading" />
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
@@ -45,29 +45,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, h, onMounted, useTemplateRef } from 'vue';
|
import { ref, reactive, h, onMounted } from 'vue';
|
||||||
import { NButton, NPopconfirm } from 'naive-ui';
|
import { NButton, NPopconfirm } from 'naive-ui';
|
||||||
import type { DataTableInst } from 'naive-ui';
|
|
||||||
import { Search } from '@vicons/carbon';
|
import { Search } from '@vicons/carbon';
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
|
import { fetchDataSetDetail, fetchDataSetPage, fetchDeleteDataSet } from "@/http/api/dataSet";
|
||||||
import DataSetModal from "./DataSetModal.vue";
|
import DataSetModal from "./DataSetModal.vue";
|
||||||
import DataSetGroup from "./DataSetGroup.vue";
|
import DataSetGroup from "./DataSetGroup.vue";
|
||||||
|
|
||||||
const tableRef = useTemplateRef<DataTableInst>("tableRef");
|
const dataSets = ref<IDataSet.Item[]>([])
|
||||||
const dataSets = ref<IDataSet.Item[]>([
|
const tableLoading = ref(false);
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
groupId:"1",
|
|
||||||
name: '323324234',
|
|
||||||
type: 'SQL'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
const showDataSetModal = ref(false)
|
const showDataSetModal = ref(false)
|
||||||
const currentDataSet = reactive<IDataSet.Item>({
|
const searchName = ref("");
|
||||||
|
const selectedGroupId = ref<IDataSet.IGroup["id"] | null>(null);
|
||||||
|
const defaultDataSet: IDataSet.Item = {
|
||||||
id: '',
|
id: '',
|
||||||
groupId:"1",
|
groupId: '',
|
||||||
name: '',
|
name: '',
|
||||||
type: 'SQL'
|
type: 'API',
|
||||||
|
method: 'GET',
|
||||||
|
api: '',
|
||||||
|
dataSourceId: '',
|
||||||
|
sql: '',
|
||||||
|
json: ''
|
||||||
|
};
|
||||||
|
const currentDataSet = reactive<IDataSet.Item>({
|
||||||
|
...defaultDataSet
|
||||||
})
|
})
|
||||||
const dataSetColumns = [
|
const dataSetColumns = [
|
||||||
{
|
{
|
||||||
@@ -119,23 +122,75 @@ const dataSetColumns = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
// 分页配置
|
// 分页配置
|
||||||
const pagination = { pageSize: 10 }
|
const pagination = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
pageCount: 1,
|
||||||
|
"on-update:page": (page: number) => {
|
||||||
|
pagination.page = page;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function getList() { }
|
function resetCurrentDataSet(data?: Partial<IDataSet.Item>) {
|
||||||
|
Object.assign(currentDataSet, defaultDataSet, data || {});
|
||||||
function handleSearch(searchText: string) {
|
|
||||||
tableRef.value?.filter({
|
|
||||||
name: [searchText]
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editDataSet(item) {
|
async function getList() {
|
||||||
Object.assign(currentDataSet, item)
|
tableLoading.value = true;
|
||||||
|
const res = await fetchDataSetPage({
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
name: searchName.value || undefined,
|
||||||
|
groupId: selectedGroupId.value ?? undefined
|
||||||
|
});
|
||||||
|
tableLoading.value = false;
|
||||||
|
dataSets.value = res.data?.items || [];
|
||||||
|
pagination.pageCount = res.data?.pages || 1;
|
||||||
|
if (res.data?.current) {
|
||||||
|
pagination.page = res.data.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(searchText: string) {
|
||||||
|
searchName.value = searchText;
|
||||||
|
pagination.page = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGroupSelect(group: IDataSet.IGroup | null) {
|
||||||
|
selectedGroupId.value = group?.id ?? null;
|
||||||
|
pagination.page = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddDataSet() {
|
||||||
|
resetCurrentDataSet({
|
||||||
|
groupId: selectedGroupId.value ?? ""
|
||||||
|
});
|
||||||
|
showDataSetModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editDataSet(item) {
|
||||||
|
const res = await fetchDataSetDetail(item.id);
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = (res.data || item) as any;
|
||||||
|
resetCurrentDataSet({
|
||||||
|
...detail,
|
||||||
|
dataSourceId: detail.dataSourceId || detail.dataSource ? String(detail.dataSourceId || detail.dataSource) : ""
|
||||||
|
});
|
||||||
showDataSetModal.value = true
|
showDataSetModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDataSet(item) {
|
async function deleteDataSet(item) {
|
||||||
dataSets.value = dataSets.value.filter(ds => ds.id !== item.id)
|
const res = await fetchDeleteDataSet(item.id);
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message?.success(t("prompt.Success to delete"));
|
||||||
|
getList();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -34,49 +34,20 @@ import { ref, reactive,onMounted } from "vue";
|
|||||||
import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui';
|
import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui';
|
||||||
import { Search, Add } from '@vicons/carbon';
|
import { Search, Add } from '@vicons/carbon';
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
|
import { fetchDataSetGroupTree, fetchDeleteDataSetGroup } from "@/http/api/dataSetGroup";
|
||||||
import DataSetGroupModal from "./DataSetGroupModal.vue";
|
import DataSetGroupModal from "./DataSetGroupModal.vue";
|
||||||
|
|
||||||
const pattern = ref("");
|
const emits = defineEmits(["select"]);
|
||||||
const data: IDataSet.IGroup[] = [
|
|
||||||
{
|
|
||||||
name: '0',
|
|
||||||
id: '0',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: '0-0',
|
|
||||||
id: '0-0',
|
|
||||||
children: [
|
|
||||||
{ name: '0-0-0', id: '0-0-0' },
|
|
||||||
{ name: '0-0-1', id: '0-0-1' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '0-1',
|
|
||||||
id: '0-1',
|
|
||||||
children: [
|
|
||||||
{ name: '0-1-0', id: '0-1-0' },
|
|
||||||
{ name: '0-1-1', id: '0-1-1' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '1',
|
|
||||||
id: '1',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: '1-0',
|
|
||||||
id: '1-0'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '1-1',
|
|
||||||
id: '1-1'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTreeData(){}
|
const pattern = ref("");
|
||||||
|
const data = ref<IDataSet.IGroup[]>([]);
|
||||||
|
const selectedGroup = ref<IDataSet.IGroup | null>(null);
|
||||||
|
const activeNode = ref<IDataSet.IGroup | null>(null);
|
||||||
|
|
||||||
|
async function getTreeData(){
|
||||||
|
const res = await fetchDataSetGroupTree();
|
||||||
|
data.value = res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
// 子节点选中,含子节点则展开
|
// 子节点选中,含子节点则展开
|
||||||
function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn {
|
function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn {
|
||||||
@@ -96,7 +67,7 @@ const currentGroup = reactive<IDataSet.IGroup>({
|
|||||||
|
|
||||||
function handleAddGroup(){
|
function handleAddGroup(){
|
||||||
currentGroup.id = "";
|
currentGroup.id = "";
|
||||||
currentGroup.pid = "";
|
currentGroup.pid = selectedGroup.value?.id ?? "";
|
||||||
currentGroup.name = "";
|
currentGroup.name = "";
|
||||||
|
|
||||||
showGroupModal.value = true;
|
showGroupModal.value = true;
|
||||||
@@ -107,8 +78,7 @@ const showDropdown = ref(false);
|
|||||||
const options = ref<DropdownOption[]>([
|
const options = ref<DropdownOption[]>([
|
||||||
{
|
{
|
||||||
label: t("home.Delete"),
|
label: t("home.Delete"),
|
||||||
key: 'delete',
|
key: 'delete'
|
||||||
disabled: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("layout.sider.script.Edit"),
|
label: t("layout.sider.script.Edit"),
|
||||||
@@ -120,9 +90,15 @@ const y = ref(0)
|
|||||||
|
|
||||||
// 绑定节点属性以实现右键菜单
|
// 绑定节点属性以实现右键菜单
|
||||||
function handleNodeProps({ option }: { option: TreeOption }) {
|
function handleNodeProps({ option }: { option: TreeOption }) {
|
||||||
|
const group = option as IDataSet.IGroup;
|
||||||
return {
|
return {
|
||||||
onClick() { },
|
onClick() {
|
||||||
|
selectedGroup.value = group;
|
||||||
|
emits("select", group);
|
||||||
|
},
|
||||||
onContextmenu(e: MouseEvent): void {
|
onContextmenu(e: MouseEvent): void {
|
||||||
|
activeNode.value = group;
|
||||||
|
selectedGroup.value = group;
|
||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
x.value = e.clientX;
|
x.value = e.clientX;
|
||||||
y.value = e.clientY;
|
y.value = e.clientY;
|
||||||
@@ -132,8 +108,34 @@ function handleNodeProps({ option }: { option: TreeOption }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDropdownSelect() {
|
async function handleDropdownSelect(key: string) {
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
|
if (!activeNode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "edit") {
|
||||||
|
Object.assign(currentGroup, {
|
||||||
|
id: activeNode.value.id,
|
||||||
|
pid: activeNode.value.pid ?? "",
|
||||||
|
name: activeNode.value.name
|
||||||
|
});
|
||||||
|
showGroupModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "delete") {
|
||||||
|
const res = await fetchDeleteDataSetGroup(activeNode.value.id);
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message?.success(t("prompt.Success to delete"));
|
||||||
|
if (selectedGroup.value?.id === activeNode.value.id) {
|
||||||
|
selectedGroup.value = null;
|
||||||
|
emits("select", null);
|
||||||
|
}
|
||||||
|
getTreeData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDropdownClickoutside() {
|
function handleDropdownClickoutside() {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-modal :show="show" @mask-click="handleClose">
|
<n-modal :show="show" @mask-click="handleClose">
|
||||||
<n-card class="w-120 max-w-600px" :title="t('home.Data set group config')">
|
<n-card class="w-120 max-w-600px" :title="t('home.Data set group config')">
|
||||||
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto">
|
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
|
||||||
<n-form-item :label="t('home.Parent group')">
|
<n-form-item :label="t('home.Parent group')">
|
||||||
<n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData"
|
<n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData"
|
||||||
check-strategy="child" show-path filterable clearable label-field="name" value-field="id" />
|
check-strategy="all" show-path filterable clearable label-field="name" value-field="id" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item :label="t('home.Group name')" path="name">
|
<n-form-item :label="t('home.Group name')" path="name">
|
||||||
<n-input v-model:value="model.name" />
|
<n-input v-model:value="model.name" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<div class="flex justify-end mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
<n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
||||||
<n-button type="primary" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
|
<n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
@@ -20,9 +20,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTemplateRef,computed } from "vue";
|
import { useTemplateRef, ref } from "vue";
|
||||||
import type { FormInst } from 'naive-ui'
|
import type { FormInst } from 'naive-ui'
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
|
import { DataSetGroupPayload, fetchCreateDataSetGroup, fetchUpdateDataSetGroup } from "@/http/api/dataSetGroup";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean,
|
show: boolean,
|
||||||
@@ -43,6 +44,7 @@ const formRef = useTemplateRef<FormInst>("formRef");
|
|||||||
const rules = {
|
const rules = {
|
||||||
name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' }
|
name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' }
|
||||||
};
|
};
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emits("update:show", false);
|
emits("update:show", false);
|
||||||
@@ -51,15 +53,21 @@ function handleClose() {
|
|||||||
function saveDataSet(e: MouseEvent) {
|
function saveDataSet(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
formRef.value?.validate((errors) => {
|
formRef.value?.validate(async (errors) => {
|
||||||
if (!errors) {
|
if (!errors) {
|
||||||
if (!props.model.id) {
|
const payload: DataSetGroupPayload = {
|
||||||
// 新增
|
id: props.model.id || undefined,
|
||||||
|
pid: props.model.pid || "",
|
||||||
} else {
|
name: props.model.name?.trim() || ""
|
||||||
// 更新
|
};
|
||||||
|
|
||||||
|
submitLoading.value = true;
|
||||||
|
const res = props.model.id ? await fetchUpdateDataSetGroup(payload) : await fetchCreateDataSetGroup(payload);
|
||||||
|
submitLoading.value = false;
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
window.$message?.success(props.model.id ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
|
||||||
|
|
||||||
// 刷新父页面表格
|
// 刷新父页面表格
|
||||||
emits("refresh");
|
emits("refresh");
|
||||||
|
|||||||
@@ -1,121 +1,330 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-modal :show="show" @mask-click="handleClose">
|
<n-modal :show="show" @mask-click="handleClose">
|
||||||
<n-card class="w-200 max-w-1200px" :title="t('home.Data set config')">
|
<n-card class="w-200 max-w-1200px" :title="t('home.Data set config')">
|
||||||
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto">
|
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
|
||||||
<n-grid :cols="24" :x-gap="24">
|
<n-grid :cols="24" :x-gap="24">
|
||||||
<n-form-item-gi :span="12" :label="t('home.Data set name')" path="name">
|
<n-form-item-gi :span="12" :label="t('home.Data set name')" path="name">
|
||||||
<n-input v-model:value="model.name" />
|
<n-input v-model:value="model.name" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<n-form-item-gi :span="12" :label="t('home.Data set group')">
|
<n-form-item-gi :span="12" :label="t('home.Data set group')" path="groupId">
|
||||||
<n-cascader v-model:value="model.groupId" expand-trigger="hover" :options="groupOptions"
|
<n-cascader
|
||||||
check-strategy="child" show-path filterable clearable label-field="name" value-field="id" />
|
v-model:value="model.groupId"
|
||||||
</n-form-item-gi>
|
expand-trigger="hover"
|
||||||
<n-form-item-gi :span="12" :label="t('home.Data set type')">
|
:options="groupOptions"
|
||||||
<n-select v-model:value="model.type" :options="setTypes" />
|
check-strategy="all"
|
||||||
</n-form-item-gi>
|
show-path
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
label-field="name"
|
||||||
|
value-field="id"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="12" :label="t('home.Data set type')" path="type">
|
||||||
|
<n-select v-model:value="model.type" :options="setTypes" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
|
||||||
<template v-if="model.type === 'API'">
|
<template v-if="model.type === 'API'">
|
||||||
<n-form-item-gi :span="12" :label="t('home.Method')">
|
<n-form-item-gi :span="12" :label="t('home.Method')" path="method">
|
||||||
<n-select v-model:value="model.type" :options="[
|
<n-select
|
||||||
{ label: 'GET', value: 'GET' },
|
:key="'dataset-method-select'"
|
||||||
{ label: 'POST', value: 'POST' },
|
v-model:value="model.method"
|
||||||
]" />
|
:options="[
|
||||||
</n-form-item-gi>
|
{ label: 'GET', value: 'GET' },
|
||||||
<n-form-item-gi :span="24" :label="t('home.API interface')">
|
{ label: 'POST', value: 'POST' },
|
||||||
<n-input v-model:value="model.api" type="textarea" />
|
]"
|
||||||
</n-form-item-gi>
|
/>
|
||||||
</template>
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="24" :label="t('home.API interface')" path="api">
|
||||||
|
<n-input v-model:value="model.api" type="textarea" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="model.type === 'SQL'">
|
<template v-else-if="model.type === 'SQL'">
|
||||||
<n-form-item-gi :span="12" :label="t('home.Data sources')">
|
<n-form-item-gi :span="12" :label="t('home.Data sources')" path="dataSourceId">
|
||||||
<n-select v-model:value="model.dataSource" :options="dataSourceOptions" />
|
<n-select :key="'dataset-datasource-select'" v-model:value="model.dataSourceId" :options="dataSourceOptions" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<n-form-item-gi :span="24" :label="`SQL(${t('home.Query only')})`">
|
<n-form-item-gi :span="24" :label="`SQL(${t('home.Query only')})`" path="sql">
|
||||||
<SQLEditor v-model:value="model.sql as string" />
|
<SQLEditor v-model:value="model.sql as string" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<n-form-item-gi :span="24" label=" ">
|
<n-form-item-gi :span="24" label=" ">
|
||||||
<n-blockquote>
|
<n-blockquote>
|
||||||
{{ t('home.Parameter is passed as ') }}
|
{{ t('home.Parameter is passed as ') }}
|
||||||
'${id}' ,
|
'${id}' ,
|
||||||
{{ t('home.For example:') }}
|
{{ t('home.For example:') }}
|
||||||
SELECT * FROM table WHERE id='${id}'
|
SELECT * FROM table WHERE id='${id}'
|
||||||
</n-blockquote>
|
</n-blockquote>
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="model.type === 'JSON'">
|
<template v-else-if="model.type === 'JSON'">
|
||||||
<n-form-item-gi :span="24" label="JSON">
|
<n-form-item-gi :span="24" label="JSON" path="json">
|
||||||
<JSONEditor v-model:value="model.json as string" />
|
<JSONEditor v-model:value="model.json as string" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-gi :span="24">
|
<n-gi :span="24">
|
||||||
<div class="flex justify-end mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
<n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
||||||
<n-button type="primary" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
|
<n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTemplateRef } from "vue";
|
import { ref, watch, computed, useTemplateRef } from "vue";
|
||||||
import type { FormInst } from 'naive-ui'
|
import type { FormInst } from "naive-ui";
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
import SQLEditor from "@/components/code/SQLEditor.vue";
|
import { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet";
|
||||||
import JSONEditor from "@/components/code/JSONEditor.vue";
|
import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup";
|
||||||
|
import { fetchDataSourceList } from "@/http/api/dataSource";
|
||||||
|
import SQLEditor from "@/components/code/SQLEditor.vue";
|
||||||
|
import JSONEditor from "@/components/code/JSONEditor.vue";
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
model?: IDataSet.Item;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
show: false,
|
||||||
|
model: () => ({
|
||||||
|
id: "",
|
||||||
|
groupId: "",
|
||||||
|
name: "",
|
||||||
|
type: "API",
|
||||||
|
method: "GET",
|
||||||
|
api: "",
|
||||||
|
dataSourceId: "",
|
||||||
|
sql: "",
|
||||||
|
json: "",
|
||||||
|
createTime: "",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const emits = defineEmits(["update:show", "refresh"]);
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const formRef = useTemplateRef<FormInst>("formRef");
|
||||||
show: boolean,
|
function createRequiredRule(message: string, trigger: Array<"input" | "blur" | "change">) {
|
||||||
model: IDataSet.Item
|
return {
|
||||||
}>(), {
|
trigger,
|
||||||
show: false,
|
validator(_: unknown, value: unknown) {
|
||||||
model: () => ({
|
if (value === undefined || value === null || value === "") {
|
||||||
id: '',
|
return new Error(message);
|
||||||
groupId: "",
|
}
|
||||||
name: '',
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
type: 'SQL',
|
return new Error(message);
|
||||||
})
|
}
|
||||||
})
|
return true;
|
||||||
const emits = defineEmits(["update:show", "refresh"]);
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const formRef = useTemplateRef<FormInst>("formRef");
|
const rules = computed(() => {
|
||||||
const rules = {
|
const baseRules: Record<string, any> = {
|
||||||
name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: 'blur' }
|
name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: ["input", "blur"] },
|
||||||
};
|
groupId: createRequiredRule("请选择数据集分组", ["change", "blur"]),
|
||||||
const groupOptions = [];
|
type: { required: true, message: "请选择数据集类型", trigger: ["change", "blur"] },
|
||||||
const setTypes = [
|
};
|
||||||
{ label: 'API', value: 'API' },
|
const type = props.model?.type;
|
||||||
{ label: 'SQL', value: 'SQL' },
|
if (type === "API") {
|
||||||
{ label: 'JSON', value: 'JSON' },
|
baseRules.method = {
|
||||||
];
|
required: true,
|
||||||
const dataSourceOptions = [];
|
message: "请选择请求方式",
|
||||||
|
trigger: ["change", "blur"],
|
||||||
|
};
|
||||||
|
baseRules.api = {
|
||||||
|
required: true,
|
||||||
|
message: "请输入接口地址",
|
||||||
|
trigger: ["input", "blur"],
|
||||||
|
};
|
||||||
|
} else if (type === "SQL") {
|
||||||
|
baseRules.dataSourceId = createRequiredRule("请选择数据源", ["change", "blur"]);
|
||||||
|
baseRules.sql = { required: true, message: "请输入 SQL 语句", trigger: ["blur"] };
|
||||||
|
} else if (type === "JSON") {
|
||||||
|
baseRules.json = { required: true, message: "请输入 JSON 数据", trigger: ["blur"] };
|
||||||
|
}
|
||||||
|
return baseRules;
|
||||||
|
});
|
||||||
|
const groupOptions = ref<IDataSet.IGroup[]>([]);
|
||||||
|
const setTypes = [
|
||||||
|
{ label: "API", value: "API" },
|
||||||
|
{ label: "SQL", value: "SQL" },
|
||||||
|
{ label: "JSON", value: "JSON" },
|
||||||
|
];
|
||||||
|
const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const isEdit = computed(() => Boolean(props.model?.id));
|
||||||
|
|
||||||
function handleClose() {
|
function normalizeDataSourceId(value: unknown): IDataSource.Item["id"] | undefined {
|
||||||
emits("update:show", false);
|
if (value === undefined || value === null || value === "") {
|
||||||
}
|
return undefined;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
function saveDataSet(e: MouseEvent) {
|
function extractList<T>(value: unknown): T[] {
|
||||||
e.preventDefault()
|
if (Array.isArray(value)) {
|
||||||
|
return value as T[];
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const nested = (value as Record<string, unknown>).result;
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
return nested as T[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
formRef.value?.validate((errors) => {
|
async function loadOptions() {
|
||||||
if (!errors) {
|
const [groupRes, dataSourceRes] = await Promise.allSettled([fetchDataSetGroupTree(), fetchDataSourceList()]);
|
||||||
if (!props.model.id) {
|
|
||||||
// 新增
|
|
||||||
|
|
||||||
} else {
|
if (groupRes.status === "fulfilled") {
|
||||||
// 更新
|
groupOptions.value = extractList<IDataSet.IGroup>(groupRes.value.data);
|
||||||
|
} else {
|
||||||
|
groupOptions.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
if (dataSourceRes.status === "fulfilled") {
|
||||||
|
const list = extractList<IDataSource.Item>(dataSourceRes.value.data);
|
||||||
|
dataSourceOptions.value = list.map(item => ({
|
||||||
|
label: item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
dataSourceOptions.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新父页面表格
|
function normalizeJsonValue(value?: IDataSet.Item["json"]) {
|
||||||
emits("refresh");
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
function resetFieldsForType(type: string, prevType?: string) {
|
||||||
}
|
if (!props.model) {
|
||||||
})
|
return;
|
||||||
}
|
}
|
||||||
|
if (type !== "API") {
|
||||||
|
props.model.method = undefined;
|
||||||
|
props.model.api = "";
|
||||||
|
}
|
||||||
|
if (type === "API" && !props.model.method) {
|
||||||
|
props.model.method = "GET";
|
||||||
|
}
|
||||||
|
if (type !== "SQL") {
|
||||||
|
props.model.dataSourceId = undefined;
|
||||||
|
props.model.sql = "";
|
||||||
|
} else if (prevType && prevType !== "SQL") {
|
||||||
|
props.model.dataSourceId = undefined;
|
||||||
|
props.model.sql = "";
|
||||||
|
}
|
||||||
|
if (type !== "JSON") {
|
||||||
|
props.model.json = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSql(sql: string) {
|
||||||
|
const trimmed = sql.trim();
|
||||||
|
if (!/^(SELECT|WITH)\b/i.test(trimmed)) {
|
||||||
|
window.$message?.error("SQL 必须以 SELECT 或 WITH 开头");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/[;]|--|\/\*/.test(trimmed)) {
|
||||||
|
window.$message?.error("SQL 不允许包含危险语句标记");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(): DataSetPayload {
|
||||||
|
const payload: DataSetPayload = {
|
||||||
|
name: props.model?.name?.trim() || "",
|
||||||
|
groupId: props.model?.groupId ?? "",
|
||||||
|
type: props.model?.type || "API",
|
||||||
|
};
|
||||||
|
if (props.model?.id) {
|
||||||
|
payload.id = props.model.id;
|
||||||
|
}
|
||||||
|
if (payload.type === "API") {
|
||||||
|
payload.method = props.model?.method;
|
||||||
|
payload.api = props.model?.api?.trim();
|
||||||
|
} else if (payload.type === "SQL") {
|
||||||
|
payload.dataSourceId = normalizeDataSourceId(props.model?.dataSourceId);
|
||||||
|
payload.sql = props.model?.sql?.trim();
|
||||||
|
} else if (payload.type === "JSON") {
|
||||||
|
payload.json = normalizeJsonValue(props.model?.json);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
show => {
|
||||||
|
if (!show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadOptions();
|
||||||
|
if (props.model) {
|
||||||
|
props.model.dataSourceId = normalizeDataSourceId(props.model.dataSourceId);
|
||||||
|
}
|
||||||
|
if (props.model?.type === "API" && !props.model.method) {
|
||||||
|
props.model.method = "GET";
|
||||||
|
}
|
||||||
|
if (props.model?.type === "JSON") {
|
||||||
|
props.model.json = normalizeJsonValue(props.model.json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.model?.type,
|
||||||
|
(next, prev) => {
|
||||||
|
if (!next || next === prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetFieldsForType(next, prev);
|
||||||
|
formRef.value?.restoreValidation();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emits("update:show", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDataSet(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
formRef.value?.validate(async errors => {
|
||||||
|
if (errors || !props.model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.model.type === "SQL" && !validateSql(props.model.sql || "")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLoading.value = true;
|
||||||
|
const payload = buildPayload();
|
||||||
|
const res = isEdit.value ? await fetchUpdateDataSet(payload) : await fetchCreateDataSet(payload);
|
||||||
|
submitLoading.value = false;
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
|
||||||
|
emits("refresh");
|
||||||
|
handleClose();
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
|
|
||||||
<n-button type="primary" @click="showDataSourceModal = true">{{ t('home.Add data source') }}</n-button>
|
<n-button type="primary" @click="handleAddDataSource">{{ t('home.Add data source') }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
<n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" striped />
|
<n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" :loading="tableLoading" striped />
|
||||||
|
|
||||||
<!-- 数据源模态框 -->
|
<!-- 数据源模态框 -->
|
||||||
<DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" />
|
<DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" />
|
||||||
@@ -21,18 +21,23 @@ import { NButton, NPopconfirm } from 'naive-ui';
|
|||||||
import type { DataTableInst } from 'naive-ui';
|
import type { DataTableInst } from 'naive-ui';
|
||||||
import { Search } from '@vicons/carbon';
|
import { Search } from '@vicons/carbon';
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
|
import { fetchDataSourceList, fetchDeleteDataSource } from "@/http/api/dataSource";
|
||||||
import DataSourceModal from "./DataSourceModal.vue";
|
import DataSourceModal from "./DataSourceModal.vue";
|
||||||
|
|
||||||
const tableRef = useTemplateRef<DataTableInst>("tableRef");
|
const tableRef = useTemplateRef<DataTableInst>("tableRef");
|
||||||
const dataSources = ref<IDataSource.Item[]>([])
|
const dataSources = ref<IDataSource.Item[]>([])
|
||||||
const showDataSourceModal = ref(false)
|
const showDataSourceModal = ref(false)
|
||||||
const currentDataSource = reactive({
|
const tableLoading = ref(false);
|
||||||
|
const defaultDataSource = {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
type: 'MySQL',
|
type: 'MySQL',
|
||||||
connectionString: '',
|
connectionString: '',
|
||||||
username: "",
|
username: "",
|
||||||
password: ""
|
password: ""
|
||||||
|
}
|
||||||
|
const currentDataSource = reactive({
|
||||||
|
...defaultDataSource
|
||||||
})
|
})
|
||||||
const dataSourceColumns = [
|
const dataSourceColumns = [
|
||||||
{
|
{
|
||||||
@@ -106,7 +111,21 @@ const dataSourceColumns = [
|
|||||||
// 分页配置
|
// 分页配置
|
||||||
const pagination = { pageSize: 10 }
|
const pagination = { pageSize: 10 }
|
||||||
|
|
||||||
function getList() { }
|
async function getList() {
|
||||||
|
tableLoading.value = true;
|
||||||
|
const res = await fetchDataSourceList();
|
||||||
|
tableLoading.value = false;
|
||||||
|
dataSources.value = res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCurrentDataSource(data?: Partial<IDataSource.Item>) {
|
||||||
|
Object.assign(currentDataSource, defaultDataSource, data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddDataSource() {
|
||||||
|
resetCurrentDataSource();
|
||||||
|
showDataSourceModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSearch(searchText: string) {
|
function handleSearch(searchText: string) {
|
||||||
tableRef.value?.filter({
|
tableRef.value?.filter({
|
||||||
@@ -115,12 +134,17 @@ function handleSearch(searchText: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editDataSource(item) {
|
function editDataSource(item) {
|
||||||
Object.assign(currentDataSource, item)
|
resetCurrentDataSource(item)
|
||||||
showDataSourceModal.value = true
|
showDataSourceModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDataSource(item) {
|
async function deleteDataSource(item) {
|
||||||
dataSources.value = dataSources.value.filter(ds => ds.id !== item.id)
|
const res = await fetchDeleteDataSource(item.id);
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message?.success(t("prompt.Success to delete"));
|
||||||
|
getList();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-modal :show="show" @mask-click="handleClose">
|
<n-modal :show="show" @mask-click="handleClose">
|
||||||
<n-card class="w-100 max-w-600px" :title="t('home.Data source config')">
|
<n-card class="w-100 max-w-600px" :title="t('home.Data source config')">
|
||||||
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto">
|
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="testLinkLoading || submitLoading">
|
||||||
<n-form-item :label="t('home.Data source name')" path="name">
|
<n-form-item :label="t('home.Data source name')" path="name">
|
||||||
<n-input v-model:value="model.name" :disabled="testLinkLoading" />
|
<n-input v-model:value="model.name" :disabled="testLinkLoading" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
<n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button>
|
<n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
<n-button :disabled="testLinkLoading || submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
|
||||||
<n-button type="primary" @click="saveDataSource">{{ t("other.Ok") }}</n-button>
|
<n-button type="primary" :loading="submitLoading" @click="saveDataSource">{{ t("other.Ok") }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +33,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, useTemplateRef } from "vue";
|
import { ref, computed, useTemplateRef } from "vue";
|
||||||
import type { FormInst } from 'naive-ui'
|
import type { FormInst } from 'naive-ui'
|
||||||
import { t } from "@/language";
|
import { t } from "@/language";
|
||||||
|
import { DataSourcePayload, fetchCreateDataSource, fetchTestDataSource, fetchUpdateDataSource } from "@/http/api/dataSource";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean,
|
show: boolean,
|
||||||
@@ -65,6 +66,8 @@ const dbTypes = [
|
|||||||
{ label: 'Oracle', value: 'Oracle' }
|
{ label: 'Oracle', value: 'Oracle' }
|
||||||
]
|
]
|
||||||
const testLinkLoading = ref(false);
|
const testLinkLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const isEdit = computed(() => Boolean(props.model.id));
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emits("update:show", false);
|
emits("update:show", false);
|
||||||
@@ -72,25 +75,52 @@ function handleClose() {
|
|||||||
|
|
||||||
function handleTestLink(e: MouseEvent) {
|
function handleTestLink(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
formRef.value?.validate((errors) => {
|
formRef.value?.validate(async (errors) => {
|
||||||
if (!errors) {
|
if (!errors) {
|
||||||
testLinkLoading.value = true;
|
testLinkLoading.value = true;
|
||||||
|
const res = await fetchTestDataSource(buildPayload(false));
|
||||||
|
testLinkLoading.value = false;
|
||||||
|
if (res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message?.success(t("prompt.Operation successful"));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPayload(includeId: boolean): DataSourcePayload {
|
||||||
|
const payload: DataSourcePayload = {
|
||||||
|
name: props.model.name?.trim() || "",
|
||||||
|
type: props.model.type,
|
||||||
|
connectionString: props.model.connectionString?.trim() || ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.model.username) {
|
||||||
|
payload.username = props.model.username;
|
||||||
|
}
|
||||||
|
if (props.model.password) {
|
||||||
|
payload.password = props.model.password;
|
||||||
|
}
|
||||||
|
if (includeId && props.model.id) {
|
||||||
|
payload.id = props.model.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function saveDataSource(e: MouseEvent) {
|
function saveDataSource(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
formRef.value?.validate((errors) => {
|
formRef.value?.validate(async (errors) => {
|
||||||
if (!errors) {
|
if (!errors) {
|
||||||
if (!props.model.id) {
|
submitLoading.value = true;
|
||||||
// 新增
|
const payload = buildPayload(true);
|
||||||
|
const res = isEdit.value ? await fetchUpdateDataSource(payload) : await fetchCreateDataSource(payload);
|
||||||
} else {
|
submitLoading.value = false;
|
||||||
// 更新
|
if (res.error) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
|
||||||
|
|
||||||
// 刷新父页面表格
|
// 刷新父页面表格
|
||||||
emits("refresh");
|
emits("refresh");
|
||||||
|
|||||||
@@ -19,26 +19,12 @@ const options = [
|
|||||||
key: 'details',
|
key: 'details',
|
||||||
icon: renderIcon(Information)
|
icon: renderIcon(Information)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: () => cpt("home.Rename").value,
|
|
||||||
key: 'rename',
|
|
||||||
icon: renderIcon(Edit)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: () => cpt("home.Delete").value,
|
label: () => cpt("home.Delete").value,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
icon: renderIcon(Delete)
|
icon: renderIcon(Delete)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: () => cpt("home.Release").value,
|
|
||||||
key: 'release',
|
|
||||||
icon: renderIcon(SendAlt)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: () => cpt("layout.header.Export").value,
|
|
||||||
key: 'export',
|
|
||||||
icon: renderIcon(Export)
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
const detail = ref();
|
const detail = ref();
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
+10
-6
@@ -1,6 +1,7 @@
|
|||||||
declare namespace IDataSource {
|
declare namespace IDataSource {
|
||||||
|
type Id = string | number;
|
||||||
interface Item {
|
interface Item {
|
||||||
id: string;
|
id: Id;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
connectionString: string;
|
connectionString: string;
|
||||||
@@ -10,21 +11,24 @@ declare namespace IDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare namespace IDataSet {
|
declare namespace IDataSet {
|
||||||
|
type Id = string | number;
|
||||||
interface Item {
|
interface Item {
|
||||||
id: string;
|
id: Id;
|
||||||
groupId: string;
|
groupId: Id;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
method?: "GET" | "POST";
|
method?: "GET" | "POST";
|
||||||
api?: string;
|
api?: string;
|
||||||
dataSource?: string;
|
dataSourceId?: Id;
|
||||||
|
dataSource?: Id;
|
||||||
sql?: string;
|
sql?: string;
|
||||||
json?: string;
|
json?: string;
|
||||||
|
createTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGroup {
|
interface IGroup {
|
||||||
id: string;
|
id: Id;
|
||||||
pid?: string;
|
pid?: Id;
|
||||||
name: string;
|
name: string;
|
||||||
children?: IGroup[];
|
children?: IGroup[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ export default defineConfig(async ({mode, command}) => {
|
|||||||
// }),
|
// }),
|
||||||
viteStaticCopy({
|
viteStaticCopy({
|
||||||
targets: [
|
targets: [
|
||||||
{ src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' }
|
{ src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' },
|
||||||
|
{ src: 'node_modules/@astral3d/engine/dist/resource/**/*', dest: 'assets/resource' }
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...plugins
|
...plugins
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @update 2025-02-14
|
* @update 2025-02-14
|
||||||
* @version 5.0.0
|
* @version 5.0.0
|
||||||
*/
|
*/
|
||||||
import { Mesh, Group, Bone } from "three";
|
import { Mesh, Group, Bone, Object3D, Texture } from "three";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js';
|
import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js';
|
||||||
import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant";
|
import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant";
|
||||||
@@ -123,6 +123,7 @@ export class Package {
|
|||||||
private textureMap: Map<string, any>;
|
private textureMap: Map<string, any>;
|
||||||
private callFunNum: { value: number; };
|
private callFunNum: { value: number; };
|
||||||
private skeletonClass: PackageSkeleton;
|
private skeletonClass: PackageSkeleton;
|
||||||
|
private dataComponentMap: Record<string, any> | null = null;
|
||||||
|
|
||||||
// 离线包相关属性
|
// 离线包相关属性
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -202,6 +203,15 @@ export class Package {
|
|||||||
* @returns {string} 返回贴图存储文件名称
|
* @returns {string} 返回贴图存储文件名称
|
||||||
*/
|
*/
|
||||||
handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string {
|
handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string {
|
||||||
|
if (imageJson.url && typeof imageJson.url === "object" && !imageJson.url.type) {
|
||||||
|
const ktx2Data = (imageJson.url as any).ktx2OriginalData;
|
||||||
|
if (ktx2Data) {
|
||||||
|
const name = imageJson.uuid + `.ktx2`;
|
||||||
|
zipData.push({ name, texture: ktx2Data });
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof imageJson.url === "string") {
|
if (typeof imageJson.url === "string") {
|
||||||
const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`;
|
const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`;
|
||||||
zipData.push({ name, texture: imageJson.url });
|
zipData.push({ name, texture: imageJson.url });
|
||||||
@@ -221,6 +231,22 @@ export class Package {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findTextureByImageUuid(mesh: Mesh, imageUuid: string): Texture | null {
|
||||||
|
if (!mesh.material) return null;
|
||||||
|
|
||||||
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||||
|
for (const material of materials) {
|
||||||
|
for (const key in material) {
|
||||||
|
const value = (material as any)[key];
|
||||||
|
if (value && value.isTexture && value.source?.uuid === imageUuid) {
|
||||||
|
return value as Texture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 mesh json
|
* 处理 mesh json
|
||||||
* @param mesh
|
* @param mesh
|
||||||
@@ -252,6 +278,12 @@ export class Package {
|
|||||||
|
|
||||||
!json.images && (json.images = []);
|
!json.images && (json.images = []);
|
||||||
|
|
||||||
|
const texture = this.findTextureByImageUuid(mesh, image.uuid);
|
||||||
|
const textureMeta = (texture as any)?.metadata;
|
||||||
|
if (textureMeta?.isKTX2 && textureMeta?.ktx2OriginalData && typeof image.url === "object") {
|
||||||
|
(image.url as any).ktx2OriginalData = textureMeta.ktx2OriginalData;
|
||||||
|
}
|
||||||
|
|
||||||
const name = this.handleImage(image, zipData)
|
const name = this.handleImage(image, zipData)
|
||||||
if(name){
|
if(name){
|
||||||
json.images.push(name);
|
json.images.push(name);
|
||||||
@@ -314,6 +346,60 @@ export class Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeDataComponentList(list: any) {
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return null;
|
||||||
|
return list.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return entry;
|
||||||
|
const config = entry.config && typeof entry.config === "object" ? { ...entry.config } : entry.config;
|
||||||
|
return { ...entry, config, data: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectDataComponentMap(root: Object3D) {
|
||||||
|
const map: Record<string, any> = {};
|
||||||
|
const condition = (obj: any) => !obj.ignore;
|
||||||
|
const visit = (obj: any) => {
|
||||||
|
const sanitized = this.sanitizeDataComponentList(obj?.dataComponent);
|
||||||
|
if (sanitized) {
|
||||||
|
map[obj.uuid] = sanitized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof (root as any).traverseByCondition === "function") {
|
||||||
|
(root as any).traverseByCondition(visit, condition);
|
||||||
|
} else {
|
||||||
|
root.traverse((obj: any) => {
|
||||||
|
if (!condition(obj)) return;
|
||||||
|
visit(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDataComponentMap(root: Object3D) {
|
||||||
|
const map = this.dataComponentMap;
|
||||||
|
if (!map || Object.keys(map).length === 0) return;
|
||||||
|
|
||||||
|
const condition = (obj: any) => !obj.ignore;
|
||||||
|
const apply = (obj: any) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(map, obj.uuid)) return;
|
||||||
|
const sanitized = this.sanitizeDataComponentList(map[obj.uuid]);
|
||||||
|
if (sanitized) {
|
||||||
|
obj.dataComponent = sanitized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof (root as any).traverseByCondition === "function") {
|
||||||
|
(root as any).traverseByCondition(apply, condition);
|
||||||
|
} else {
|
||||||
|
root.traverse((obj: any) => {
|
||||||
|
if (!condition(obj)) return;
|
||||||
|
apply(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 group 分组各打包为1个zip文件
|
* 按 group 分组各打包为1个zip文件
|
||||||
* @param {IPackConfig} packConfig
|
* @param {IPackConfig} packConfig
|
||||||
@@ -422,6 +508,14 @@ export class Package {
|
|||||||
console.log(sceneZipData,drawingInfo)
|
console.log(sceneZipData,drawingInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataComponents = this.collectDataComponentMap(this.viewer.scene);
|
||||||
|
if (Object.keys(dataComponents).length > 0) {
|
||||||
|
sceneZipData.push({
|
||||||
|
name: "data_components.json",
|
||||||
|
json: JSON.stringify(dataComponents)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 项目配置
|
// 项目配置
|
||||||
sceneZipData.push({
|
sceneZipData.push({
|
||||||
name: "config.json",
|
name: "config.json",
|
||||||
@@ -430,6 +524,8 @@ export class Package {
|
|||||||
xr: App.project.getKey("xr"),
|
xr: App.project.getKey("xr"),
|
||||||
// 项目渲染器配置
|
// 项目渲染器配置
|
||||||
renderer: App.project.getKey("renderer"),
|
renderer: App.project.getKey("renderer"),
|
||||||
|
// BVH 配置
|
||||||
|
bvh: App.project.getKey("bvh"),
|
||||||
// 项目级联阴影映射
|
// 项目级联阴影映射
|
||||||
csm: App.project.getKey("csm"),
|
csm: App.project.getKey("csm"),
|
||||||
// 项目后处理配置
|
// 项目后处理配置
|
||||||
@@ -712,16 +808,175 @@ export class Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private decodeUint8Text(data: Uint8Array): string {
|
||||||
|
return new TextDecoder().decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseOfflineManifest(unzipped: Record<string, Uint8Array>): any | null {
|
||||||
|
const manifestData = unzipped["offline.json"];
|
||||||
|
if (!manifestData) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.decodeUint8Text(manifestData));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOfflineFlatPackages(unzipped: Record<string, Uint8Array>, packagesPrefix: string): Map<string, Record<string, Uint8Array>> {
|
||||||
|
const prefix = packagesPrefix.endsWith("/") ? packagesPrefix : `${packagesPrefix}/`;
|
||||||
|
const map = new Map<string, Record<string, Uint8Array>>();
|
||||||
|
|
||||||
|
for (const [name, data] of Object.entries(unzipped)) {
|
||||||
|
if (name === "offline.json") continue;
|
||||||
|
if (!name.startsWith(prefix)) continue;
|
||||||
|
|
||||||
|
const rest = name.slice(prefix.length);
|
||||||
|
const splitIndex = rest.indexOf("/");
|
||||||
|
if (splitIndex <= 0) continue;
|
||||||
|
|
||||||
|
const baseName = rest.slice(0, splitIndex);
|
||||||
|
const innerPath = rest.slice(splitIndex + 1);
|
||||||
|
if (!innerPath) continue;
|
||||||
|
|
||||||
|
let pkg = map.get(baseName);
|
||||||
|
if (!pkg) {
|
||||||
|
pkg = {};
|
||||||
|
map.set(baseName, pkg);
|
||||||
|
}
|
||||||
|
pkg[innerPath] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unzipJsZipToMap(zipInput: Blob | ArrayBuffer | Uint8Array): Promise<Record<string, Uint8Array>> {
|
||||||
|
const jszip = new JSZip();
|
||||||
|
const zipRes = await jszip.loadAsync(zipInput as any);
|
||||||
|
const out: Record<string, Uint8Array> = {};
|
||||||
|
for (const key in zipRes.files) {
|
||||||
|
if (zipRes.files[key].dir) continue;
|
||||||
|
const fileName = zipRes.files[key].name;
|
||||||
|
const content = await zipRes.file(fileName)?.async("uint8array");
|
||||||
|
if (content) out[fileName] = content;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildOfflinePackageMapFromFiles(files: File[]): Promise<Map<string, Uint8Array>> {
|
||||||
|
const map = new Map<string, Uint8Array>();
|
||||||
|
for (const file of files) {
|
||||||
|
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const normalized = this.normalizeOfflinePackageName(file.name);
|
||||||
|
map.set(normalized, buffer);
|
||||||
|
|
||||||
|
const uuid = this.extractUuidFromName(file.name);
|
||||||
|
if (uuid) {
|
||||||
|
const canonical = this.normalizeOfflinePackageName(uuid.toLowerCase());
|
||||||
|
if (!map.has(canonical)) {
|
||||||
|
map.set(canonical, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initOfflineSource(unpackConfig: IUnpackConfig): Promise<void> {
|
||||||
|
this.isOfflineImportContext = Boolean(unpackConfig.offlineBundle || (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0));
|
||||||
|
this.offlineZipMap = null;
|
||||||
|
this.offlineEntryZip = null;
|
||||||
|
this.offlineFlatPackages = null;
|
||||||
|
this.offlineFlatEntryBase = null;
|
||||||
|
|
||||||
|
const setupFlat = (unzipped: Record<string, Uint8Array>, manifest?: any, fallbackEntry?: string) => {
|
||||||
|
const packagesPrefix = manifest?.packagesPrefix || this.offlinePackagesPrefix;
|
||||||
|
this.offlineFlatPackages = this.buildOfflineFlatPackages(unzipped, packagesPrefix);
|
||||||
|
const entryName = manifest?.entry
|
||||||
|
? this.normalizeOfflinePackageName(String(manifest.entry))
|
||||||
|
: (fallbackEntry ? this.normalizeOfflinePackageName(fallbackEntry) : undefined);
|
||||||
|
const entryBase = manifest?.entryBase || (entryName ? this.getOfflinePackageBaseName(entryName) : undefined);
|
||||||
|
if (entryBase) this.offlineFlatEntryBase = entryBase;
|
||||||
|
if (entryName) this.offlineEntryZip = entryName;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (unpackConfig.offlineBundle) {
|
||||||
|
let u8: Uint8Array;
|
||||||
|
if (unpackConfig.offlineBundle instanceof Uint8Array) {
|
||||||
|
u8 = unpackConfig.offlineBundle;
|
||||||
|
} else if (unpackConfig.offlineBundle instanceof ArrayBuffer) {
|
||||||
|
u8 = new Uint8Array(unpackConfig.offlineBundle);
|
||||||
|
} else {
|
||||||
|
u8 = new Uint8Array(await unpackConfig.offlineBundle.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
const unzipped = await this.unzipJsZipToMap(u8);
|
||||||
|
const manifest = this.parseOfflineManifest(unzipped);
|
||||||
|
|
||||||
|
if (manifest?.mode === "single") {
|
||||||
|
setupFlat(unzipped, manifest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string, Uint8Array>();
|
||||||
|
for (const [name, data] of Object.entries(unzipped)) {
|
||||||
|
if (name === "offline.json") continue;
|
||||||
|
if (name.toLowerCase().endsWith(".astral")) {
|
||||||
|
map.set(this.normalizeOfflinePackageName(name), data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.offlineZipMap = map;
|
||||||
|
|
||||||
|
if (manifest?.entry) {
|
||||||
|
this.offlineEntryZip = this.normalizeOfflinePackageName(String(manifest.entry));
|
||||||
|
} else if (unpackConfig.offlineEntry) {
|
||||||
|
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
|
||||||
|
}
|
||||||
|
} else if (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0) {
|
||||||
|
if (unpackConfig.offlineFiles.length === 1) {
|
||||||
|
const file = unpackConfig.offlineFiles[0];
|
||||||
|
try {
|
||||||
|
const unzipped = await this.unzipJsZipToMap(await file.arrayBuffer());
|
||||||
|
const manifest = this.parseOfflineManifest(unzipped);
|
||||||
|
if (manifest?.mode === "single") {
|
||||||
|
setupFlat(unzipped, manifest, file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 兼容直接传入 .astral 分包文件的场景,忽略单包探测失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.offlineZipMap = await this.buildOfflinePackageMapFromFiles(unpackConfig.offlineFiles);
|
||||||
|
if (unpackConfig.offlineEntry) {
|
||||||
|
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.offlineZipMap && !this.offlineEntryZip) {
|
||||||
|
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从首包开始解包
|
* 从首包开始解包
|
||||||
* @param {IUnpackConfig} unpackConfig
|
* @param {IUnpackConfig} unpackConfig
|
||||||
*/
|
*/
|
||||||
public unpack(unpackConfig: IUnpackConfig) {
|
public async unpack(unpackConfig: IUnpackConfig) {
|
||||||
unpackConfig.onProgress && unpackConfig.onProgress(0);
|
unpackConfig.onProgress && unpackConfig.onProgress(0);
|
||||||
let totalZipNumber = 0, progress = 0;
|
let totalZipNumber = 0, progress = 0;
|
||||||
|
|
||||||
const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/);
|
// 每次解包前确保 loader 可用
|
||||||
this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/")));
|
if (!this.loader) {
|
||||||
|
this.loader = new ObjectLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initOfflineSource(unpackConfig);
|
||||||
|
|
||||||
|
if (!this.offlineZipMap && !this.offlineFlatPackages) {
|
||||||
|
const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/);
|
||||||
|
this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/")));
|
||||||
|
} else {
|
||||||
|
this.prefix_url = "";
|
||||||
|
}
|
||||||
|
|
||||||
// indexDb存储
|
// indexDb存储
|
||||||
// const db = window.VIEWPORT.modules["db"];
|
// const db = window.VIEWPORT.modules["db"];
|
||||||
@@ -743,8 +998,12 @@ export class Package {
|
|||||||
that.imagesMap.clear();
|
that.imagesMap.clear();
|
||||||
that.materialsMap.clear();
|
that.materialsMap.clear();
|
||||||
that.textureMap.clear();
|
that.textureMap.clear();
|
||||||
// @ts-ignore 清除loader
|
that.offlineZipMap = null;
|
||||||
that.loader = undefined;
|
that.offlineEntryZip = null;
|
||||||
|
that.offlineFlatPackages = null;
|
||||||
|
that.offlineFlatEntryBase = null;
|
||||||
|
that.dataComponentMap = null;
|
||||||
|
// 不清除 loader,避免连续导入时被上一轮异步收尾误清理
|
||||||
}
|
}
|
||||||
|
|
||||||
const complete = () => {
|
const complete = () => {
|
||||||
@@ -806,6 +1065,11 @@ export class Package {
|
|||||||
App.FPS = Number(configJson.renderer.fps);
|
App.FPS = Number(configJson.renderer.fps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bvhConfig = (configJson as any)?.bvh;
|
||||||
|
if (bvhConfig) {
|
||||||
|
App.project.setKey("bvh", bvhConfig);
|
||||||
|
}
|
||||||
|
|
||||||
if(configJson.csm){
|
if(configJson.csm){
|
||||||
const projectCSM = App.project.getKey("csm");
|
const projectCSM = App.project.getKey("csm");
|
||||||
let _csmNotChange = true;
|
let _csmNotChange = true;
|
||||||
@@ -859,6 +1123,9 @@ export class Package {
|
|||||||
|
|
||||||
unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson);
|
unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson);
|
||||||
|
|
||||||
|
this.applyDataComponentMap(App.scene as unknown as Object3D);
|
||||||
|
this.dataComponentMap = null;
|
||||||
|
|
||||||
// 防止项目只有一个包的情况造成不触发proxy set
|
// 防止项目只有一个包的情况造成不触发proxy set
|
||||||
if (this.callFunNum.value === 0) {
|
if (this.callFunNum.value === 0) {
|
||||||
this.callFunNum.value = 0;
|
this.callFunNum.value = 0;
|
||||||
@@ -872,15 +1139,12 @@ export class Package {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const networkGet = () => {
|
const parseSceneZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
|
||||||
// 下载场景包
|
|
||||||
fetch(this.viewer.options.request?.baseUrl + unpackConfig.url)
|
|
||||||
.then(zipRes => zipRes.blob())
|
|
||||||
.then(async (file) => {
|
|
||||||
unpackConfig.onProgress && unpackConfig.onProgress(1);
|
unpackConfig.onProgress && unpackConfig.onProgress(1);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined;
|
let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined;
|
||||||
|
let dataComponentMap: Record<string, any> | null = null;
|
||||||
|
|
||||||
// 开始解压首包
|
// 开始解压首包
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
@@ -898,7 +1162,7 @@ export class Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await zip.loadAsync(file);
|
const res = await zip.loadAsync(file as any);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* res.files里包含整个zip里的文件描述、目录描述列表
|
* res.files里包含整个zip里的文件描述、目录描述列表
|
||||||
@@ -917,6 +1181,9 @@ export class Package {
|
|||||||
} else if (fileName === "config.json") { // 项目配置json
|
} else if (fileName === "config.json") { // 项目配置json
|
||||||
const content = await res.file(fileName)?.async('string') as string;
|
const content = await res.file(fileName)?.async('string') as string;
|
||||||
configJson = JSON.parse(content);
|
configJson = JSON.parse(content);
|
||||||
|
} else if (fileName === "data_components.json") {
|
||||||
|
const content = await res.file(fileName)?.async('string') as string;
|
||||||
|
dataComponentMap = JSON.parse(content);
|
||||||
} else if (fileName.substring(0, 9) === "Textures/") {
|
} else if (fileName.substring(0, 9) === "Textures/") {
|
||||||
/**
|
/**
|
||||||
* 贴图
|
* 贴图
|
||||||
@@ -928,6 +1195,11 @@ export class Package {
|
|||||||
// 转换回贴图原始信息,存入map
|
// 转换回贴图原始信息,存入map
|
||||||
const content = await res.file(fileName)?.async('arraybuffer');
|
const content = await res.file(fileName)?.async('arraybuffer');
|
||||||
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
||||||
|
} else if (/\.ktx2$/i.test(fileName)) {
|
||||||
|
const content = await res.file(fileName)?.async('arraybuffer') as ArrayBuffer;
|
||||||
|
const blob = new Blob([content], { type: "image/ktx2" });
|
||||||
|
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
|
||||||
|
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
|
||||||
} else {
|
} else {
|
||||||
const content = await res.file(fileName)?.async('string')
|
const content = await res.file(fileName)?.async('string')
|
||||||
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
||||||
@@ -964,6 +1236,7 @@ export class Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalZipNumber = sceneJson.totalZipNumber || 0;
|
totalZipNumber = sceneJson.totalZipNumber || 0;
|
||||||
|
this.dataComponentMap = dataComponentMap;
|
||||||
|
|
||||||
// 贴图还原至sceneJson
|
// 贴图还原至sceneJson
|
||||||
sceneJson.scene.images = sceneJson.scene.images.map((item) => {
|
sceneJson.scene.images = sceneJson.scene.images.map((item) => {
|
||||||
@@ -1003,12 +1276,42 @@ export class Package {
|
|||||||
|
|
||||||
// 解压处理好的数据添加至 indexDB -> Msy3D -> scene
|
// 解压处理好的数据添加至 indexDB -> Msy3D -> scene
|
||||||
// db.setItem(dbKey, sceneJson);
|
// db.setItem(dbKey, sceneJson);
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const networkGet = async () => {
|
||||||
|
if (this.offlineFlatPackages) {
|
||||||
|
const entryBase = this.offlineFlatEntryBase || this.getOfflinePackageBaseName(this.offlineEntryZip || unpackConfig.url);
|
||||||
|
const entry = this.offlineFlatPackages.get(entryBase);
|
||||||
|
if (!entry) {
|
||||||
|
console.error(`[Package] 离线包缺少首包内容: ${entryBase}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipData = await this.zipRawFiles(entry);
|
||||||
|
await parseSceneZip(zipData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.offlineZipMap) {
|
||||||
|
const entryName = this.offlineEntryZip || this.normalizeOfflinePackageName(unpackConfig.url);
|
||||||
|
const zipData = this.offlineZipMap.get(entryName);
|
||||||
|
if (!zipData) {
|
||||||
|
console.error(`[Package] 离线包缺少首包: ${entryName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await parseSceneZip(zipData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载场景包
|
||||||
|
const file = await fetch(this.viewer.options.request?.baseUrl + unpackConfig.url).then(zipRes => zipRes.blob());
|
||||||
|
await parseSceneZip(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// db.getItem(dbKey).then((dbData) => {
|
// db.getItem(dbKey).then((dbData) => {
|
||||||
// if (dbData === undefined) {
|
// if (dbData === undefined) {
|
||||||
networkGet();
|
await networkGet();
|
||||||
// }else{
|
// }else{
|
||||||
// this.recordUuid(dbData.scene);
|
// this.recordUuid(dbData.scene);
|
||||||
//
|
//
|
||||||
@@ -1038,12 +1341,17 @@ export class Package {
|
|||||||
//const db = window.VIEWPORT.modules["db"];
|
//const db = window.VIEWPORT.modules["db"];
|
||||||
//const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`;
|
//const dbTable = `${useProjectState.getState().sceneId}-${useProjectState.getState().info.id}`;
|
||||||
|
|
||||||
|
// 在调用时捕获 loader 引用,防止 done() 清除 this.loader 后重试路径报错
|
||||||
|
const loader = this.loader;
|
||||||
|
|
||||||
// map 存储 json 解析完成后执行的 function; key 为 uuid
|
// map 存储 json 解析完成后执行的 function; key 为 uuid
|
||||||
const funcMap = new Map<string, Function>();
|
const funcMap = new Map<string, Function>();
|
||||||
|
|
||||||
const check = (object, group) => {
|
const check = (object, group) => {
|
||||||
// 检查数据是否已完善
|
// 检查数据是否已完善
|
||||||
let isDone = true;
|
let isDone = true;
|
||||||
|
|
||||||
|
const useMaterials = new Set<string>();
|
||||||
object.children.forEach((child) => {
|
object.children.forEach((child) => {
|
||||||
// 检查几何数据是否都已拥有
|
// 检查几何数据是否都已拥有
|
||||||
if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) {
|
if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) {
|
||||||
@@ -1055,30 +1363,9 @@ export class Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// material->texture->image
|
// material->texture->image
|
||||||
if (child.material && group.materials?.findIndex((material) => material.uuid === child.material) === -1) {
|
if (child.material) {
|
||||||
if (!this.materialsMap.has(child.material)) {
|
const materialUUIDs = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
isDone = false;
|
materialUUIDs.forEach((materialUuid) => useMaterials.add(materialUuid));
|
||||||
} else {
|
|
||||||
group.materials.push(this.materialsMap.get(child.material));
|
|
||||||
|
|
||||||
const material = this.materialsMap.get(child.material);
|
|
||||||
if (material.map && group.textures?.findIndex((texture) => texture.uuid === material.map) === -1) {
|
|
||||||
if (!this.textureMap.has(material.map)) {
|
|
||||||
isDone = false;
|
|
||||||
} else {
|
|
||||||
group.textures.push(this.textureMap.get(material.map));
|
|
||||||
|
|
||||||
const texture = this.textureMap.get(material.map);
|
|
||||||
if (texture.image && group.images?.findIndex((image) => image.uuid === texture.image) === -1) {
|
|
||||||
if (!this.imagesMap.has(texture.image)) {
|
|
||||||
isDone = false;
|
|
||||||
} else {
|
|
||||||
group.images.push(this.imagesMap.get(texture.image));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child.children?.length > 0 && isDone) {
|
if (child.children?.length > 0 && isDone) {
|
||||||
@@ -1086,12 +1373,80 @@ export class Package {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!isDone) return isDone;
|
||||||
|
|
||||||
|
const useTextures = new Set<string>();
|
||||||
|
const textureFields = [
|
||||||
|
"map", "alphaMap", "aoMap", "anisotropyMap", "bumpMap", "clearcoatMap", "clearcoatNormalMap", "clearcoatRoughnessMap",
|
||||||
|
"displacementMap", "envMap", "emissiveMap", "iridescenceMap", "iridescenceThicknessMap", "lightMap", "sheenColorMap",
|
||||||
|
"sheenRoughnessMap", "specularColorMap", "specularIntensityMap", "specularMap", "thicknessMap", "transmissionMap",
|
||||||
|
"metalnessMap", "roughnessMap", "normalMap", "gradientMap"
|
||||||
|
];
|
||||||
|
|
||||||
|
useMaterials.forEach((materialUuid) => {
|
||||||
|
if (!isDone) return;
|
||||||
|
|
||||||
|
const material = this.materialsMap.get(materialUuid);
|
||||||
|
if (!material) {
|
||||||
|
isDone = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.materials?.findIndex((item) => item.uuid === materialUuid) === -1) {
|
||||||
|
group.materials.push(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
textureFields.forEach((field) => {
|
||||||
|
const textureUuid = material[field];
|
||||||
|
if (textureUuid) useTextures.add(textureUuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDone) return isDone;
|
||||||
|
|
||||||
|
const useImages = new Set<string>();
|
||||||
|
useTextures.forEach((textureUuid) => {
|
||||||
|
if (!isDone) return;
|
||||||
|
|
||||||
|
const texture = this.textureMap.get(textureUuid);
|
||||||
|
if (!texture) {
|
||||||
|
isDone = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.textures?.findIndex((item) => item.uuid === textureUuid) === -1) {
|
||||||
|
group.textures.push(texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (texture.image) useImages.add(texture.image);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDone) return isDone;
|
||||||
|
|
||||||
|
useImages.forEach((imageUuid) => {
|
||||||
|
if (!isDone) return;
|
||||||
|
if (!this.imagesMap.has(imageUuid)) {
|
||||||
|
isDone = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.images?.findIndex((item) => item.uuid === imageUuid) === -1) {
|
||||||
|
group.images.push(this.imagesMap.get(imageUuid));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return isDone;
|
return isDone;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parse = (json) => {
|
const parse = (json) => {
|
||||||
if (check(json.object, json)) {
|
if (check(json.object, json)) {
|
||||||
this.loader.parse(json, (group) => {
|
if (!loader) {
|
||||||
|
// 防止早退导致 callFunNum 无法归零,进度卡住
|
||||||
|
this.callFunNum.value--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loader.parse(json, (group) => {
|
||||||
const bones: Bone[] = [];
|
const bones: Bone[] = [];
|
||||||
group.getObjectsByProperty("type", "Bone", bones);
|
group.getObjectsByProperty("type", "Bone", bones);
|
||||||
if (bones.length > 0) {
|
if (bones.length > 0) {
|
||||||
@@ -1123,7 +1478,10 @@ export class Package {
|
|||||||
funcMap.forEach((func, uuid) => {
|
funcMap.forEach((func, uuid) => {
|
||||||
func.call(this, uuid, group, rootGroupUuid);
|
func.call(this, uuid, group, rootGroupUuid);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -1132,112 +1490,135 @@ export class Package {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseGroupZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
let json: GroupJson;
|
||||||
|
|
||||||
|
// 几何数据数组
|
||||||
|
let geometries: Array<any> = [];
|
||||||
|
|
||||||
|
const unzipDone = () => {
|
||||||
|
// 贴图还原至sceneJson
|
||||||
|
json.images = json.images.map((item) => {
|
||||||
|
const nameSplit = item.split(".");
|
||||||
|
if (nameSplit[1] === "env") {
|
||||||
|
const urlSplit = nameSplit[0].split("!");
|
||||||
|
return this.imagesMap.get(urlSplit[3])
|
||||||
|
} else {
|
||||||
|
return this.imagesMap.get(nameSplit[0])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 几何数据还原至sceneJson
|
||||||
|
json.geometries = geometries;
|
||||||
|
|
||||||
|
this.recordUuid(json);
|
||||||
|
|
||||||
|
// 遍历children,拉取group zip还原
|
||||||
|
const children: any = [];
|
||||||
|
json.object.children.forEach((childUuid) => {
|
||||||
|
if (typeof childUuid === "string") {
|
||||||
|
// 保存uuid对应的function
|
||||||
|
funcMap.set(childUuid, this.unpackGroup);
|
||||||
|
|
||||||
|
this.callFunNum.value++;
|
||||||
|
} else {
|
||||||
|
children.push(childUuid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
json.object.children = children;
|
||||||
|
|
||||||
|
parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await zip.loadAsync(file as any);
|
||||||
|
let num = new Proxy({ value: Object.keys(res.files).length }, {
|
||||||
|
set(target, p, value) {
|
||||||
|
target[p] = value;
|
||||||
|
if (value === 0) {
|
||||||
|
unzipDone();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let key in res.files) {
|
||||||
|
//判断是否是目录
|
||||||
|
if (!res.files[key].dir) {
|
||||||
|
const fileName = res.files[key].name;
|
||||||
|
|
||||||
|
//找到我们压缩包所需要的json文件
|
||||||
|
if (fileName === `${uuid}.json`) { // 场景json
|
||||||
|
res.file(fileName)?.async('string').then(content => {
|
||||||
|
json = JSON.parse(content);
|
||||||
|
num.value--;
|
||||||
|
})
|
||||||
|
} else if (fileName.substring(0, 9) === "Textures/") {
|
||||||
|
if (/\.env$/.test(fileName)) {
|
||||||
|
// 转换回贴图原始信息,存入map
|
||||||
|
res.file(fileName)?.async('arraybuffer').then(content => {
|
||||||
|
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
||||||
|
num.value--;
|
||||||
|
})
|
||||||
|
} else if (/\.ktx2$/i.test(fileName)) {
|
||||||
|
res.file(fileName)?.async('arraybuffer').then(content => {
|
||||||
|
const blob = new Blob([content], { type: "image/ktx2" });
|
||||||
|
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
|
||||||
|
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
|
||||||
|
num.value--;
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res.file(fileName)?.async('string').then(content => {
|
||||||
|
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
||||||
|
num.value--;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) {
|
||||||
|
res.file(fileName)?.async('string').then(content => {
|
||||||
|
geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
|
||||||
|
num.value--;
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
num.value--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
num.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getByNetwork = () => {
|
const getByNetwork = () => {
|
||||||
|
if (this.offlineFlatPackages) {
|
||||||
|
const entry = this.offlineFlatPackages.get(uuid);
|
||||||
|
if (!entry) {
|
||||||
|
console.error(`[Package] 离线包缺少分包内容: ${uuid}`);
|
||||||
|
this.callFunNum.value--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zipRawFiles(entry).then((zipData) => {
|
||||||
|
parseGroupZip(zipData);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.offlineZipMap) {
|
||||||
|
const fileName = this.normalizeOfflinePackageName(uuid);
|
||||||
|
const normalizedLowerName = this.normalizeOfflinePackageName(uuid.toLowerCase());
|
||||||
|
const zipData = this.offlineZipMap.get(fileName) || this.offlineZipMap.get(normalizedLowerName);
|
||||||
|
if (!zipData) {
|
||||||
|
console.error(`[Package] 离线包缺少分包: ${fileName}`);
|
||||||
|
this.callFunNum.value--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseGroupZip(zipData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Package._fetch(`${this.prefix_url}/${uuid}.zip`, {
|
Package._fetch(`${this.prefix_url}/${uuid}.zip`, {
|
||||||
onSuccess: (zipRes) => {
|
onSuccess: (zipRes) => {
|
||||||
const file = zipRes.blob();
|
parseGroupZip(zipRes.blob());
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
let json: GroupJson;
|
|
||||||
|
|
||||||
// 几何数据数组
|
|
||||||
let geometries: Array<any> = [];
|
|
||||||
|
|
||||||
const unzipDone = () => {
|
|
||||||
// 贴图还原至sceneJson
|
|
||||||
json.images = json.images.map((item) => {
|
|
||||||
const nameSplit = item.split(".");
|
|
||||||
if (nameSplit[1] === "env") {
|
|
||||||
const urlSplit = nameSplit[0].split("!");
|
|
||||||
return this.imagesMap.get(urlSplit[3])
|
|
||||||
} else {
|
|
||||||
return this.imagesMap.get(nameSplit[0])
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 几何数据还原至sceneJson
|
|
||||||
json.geometries = geometries;
|
|
||||||
|
|
||||||
this.recordUuid(json);
|
|
||||||
|
|
||||||
// 遍历children,拉取group zip还原
|
|
||||||
const children: any = [];
|
|
||||||
json.object.children.forEach((uuid) => {
|
|
||||||
if (typeof uuid === "string") {
|
|
||||||
// 保存uuid对应的function
|
|
||||||
funcMap.set(uuid, this.unpackGroup);
|
|
||||||
|
|
||||||
this.callFunNum.value++;
|
|
||||||
} else {
|
|
||||||
children.push(uuid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
json.object.children = children;
|
|
||||||
|
|
||||||
//json.object.groupChildren = [...funcMap.keys()];
|
|
||||||
|
|
||||||
// 解压处理好的数据添加至 indexDB -> Msy3D -> ${dbTable}
|
|
||||||
// db.setItem(`${uuid}.zip`, json,dbTable);
|
|
||||||
|
|
||||||
parse(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
zip.loadAsync(file as Blob).then(res => {
|
|
||||||
let num = new Proxy({ value: Object.keys(res.files).length }, {
|
|
||||||
set(target, p, value) {
|
|
||||||
target[p] = value;
|
|
||||||
if (value === 0) {
|
|
||||||
unzipDone();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (let key in res.files) {
|
|
||||||
//判断是否是目录
|
|
||||||
if (!res.files[key].dir) {
|
|
||||||
const fileName = res.files[key].name;
|
|
||||||
|
|
||||||
//找到我们压缩包所需要的json文件
|
|
||||||
if (fileName === `${uuid}.json`) { // 场景json
|
|
||||||
res.file(fileName)?.async('string').then(content => {
|
|
||||||
//得到scene.json文件的内容
|
|
||||||
json = JSON.parse(content);
|
|
||||||
|
|
||||||
num.value--;
|
|
||||||
})
|
|
||||||
} else if (fileName.substring(0, 9) === "Textures/") {
|
|
||||||
/**
|
|
||||||
* 贴图
|
|
||||||
* 分为两种情况:
|
|
||||||
* 1.贴图为env格式(type!width!height!uuid.env),转换为arraybuffer格式,存入map
|
|
||||||
* 2.贴图为普通图片格式,直接存入map
|
|
||||||
**/
|
|
||||||
if (/\.env$/.test(fileName)) {
|
|
||||||
// 转换回贴图原始信息,存入map
|
|
||||||
res.file(fileName)?.async('arraybuffer').then(content => {
|
|
||||||
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
|
||||||
|
|
||||||
num.value--;
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
res.file(fileName)?.async('string').then(content => {
|
|
||||||
this.unGzipImage(fileName.replace("Textures/", ""), content);
|
|
||||||
|
|
||||||
num.value--;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) {
|
|
||||||
res.file(fileName)?.async('string').then(content => {
|
|
||||||
geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
|
|
||||||
|
|
||||||
num.value--;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
num.value--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1297,6 +1678,11 @@ export class Package {
|
|||||||
this.prefix_url = "";
|
this.prefix_url = "";
|
||||||
this.callFunNum = { value: 0 }; // 重置为初始状态
|
this.callFunNum = { value: 0 }; // 重置为初始状态
|
||||||
this.totalSize = 0;
|
this.totalSize = 0;
|
||||||
|
this.offlineZipMap = null;
|
||||||
|
this.offlineEntryZip = null;
|
||||||
|
this.offlineFlatPackages = null;
|
||||||
|
this.offlineFlatEntryBase = null;
|
||||||
|
this.dataComponentMap = null;
|
||||||
|
|
||||||
// 5. 释放 viewer 引用(注意:不销毁 viewer,仅移除引用)
|
// 5. 释放 viewer 引用(注意:不销毁 viewer,仅移除引用)
|
||||||
this.viewer = null as any;
|
this.viewer = null as any;
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export default class Path extends THREE.Mesh {
|
|||||||
if (Path.flowSignalBound) return;
|
if (Path.flowSignalBound) return;
|
||||||
Path.flowSignalBound = true;
|
Path.flowSignalBound = true;
|
||||||
useAddSignal("sceneRendered", Path.handleFlowTick);
|
useAddSignal("sceneRendered", Path.handleFlowTick);
|
||||||
|
// 立即请求一帧渲染,防止场景静止时 sceneRendered 永远不触发导致流动停止
|
||||||
|
(App.viewer as any)?.pluginRequestRender?.(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static unbindFlowSignal() {
|
private static unbindFlowSignal() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {Timer} from 'three/examples/jsm/misc/Timer.js';
|
|||||||
import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer";
|
import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer";
|
||||||
import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js";
|
import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js";
|
||||||
import {TransformControls} from "three/examples/jsm/controls/TransformControls.js";
|
import {TransformControls} from "three/examples/jsm/controls/TransformControls.js";
|
||||||
|
import TWEEN from "three/examples/jsm/libs/tween.module.js";
|
||||||
import App from "../app/App";
|
import App from "../app/App";
|
||||||
import {ViewerOptions} from "./ViewerOptions";
|
import {ViewerOptions} from "./ViewerOptions";
|
||||||
import {PluginManager} from "@/core/plugin/plugin";
|
import {PluginManager} from "@/core/plugin/plugin";
|
||||||
@@ -119,6 +120,11 @@ export interface ViewerModules {
|
|||||||
tilesManage:TilesManage,
|
tilesManage:TilesManage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstallScriptsOptions {
|
||||||
|
invokeLoaded?: boolean;
|
||||||
|
loadedScriptNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
CameraControls.install({
|
CameraControls.install({
|
||||||
THREE: {
|
THREE: {
|
||||||
Vector2: THREE.Vector2,
|
Vector2: THREE.Vector2,
|
||||||
@@ -140,25 +146,14 @@ const onDoubleClickPosition = new THREE.Vector2();
|
|||||||
// 表示animate()函数被多次调用累积时间,用于限制FPS
|
// 表示animate()函数被多次调用累积时间,用于限制FPS
|
||||||
let timeStamp = 0;
|
let timeStamp = 0;
|
||||||
|
|
||||||
// 事件绑定
|
|
||||||
const Fn: any = {
|
|
||||||
pointerdown: null,
|
|
||||||
pointerup: null,
|
|
||||||
pointermove: null,
|
|
||||||
keydown: null,
|
|
||||||
keyup: null,
|
|
||||||
touchstart: null,
|
|
||||||
dblclick: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 脚本管理数据结构
|
// 脚本管理数据结构
|
||||||
type EventHandlers = {
|
type EventHandlers = {
|
||||||
[eventName: string]: {
|
[eventName: string]: {
|
||||||
[uuid: string]: Function[];
|
[uuid: string]: Function[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// 脚本中可写的所有事件
|
|
||||||
let events: EventHandlers = {
|
const createScriptEvents = (): EventHandlers => ({
|
||||||
loaded: {},
|
loaded: {},
|
||||||
beforeAnimation: {},
|
beforeAnimation: {},
|
||||||
afterAnimation: {},
|
afterAnimation: {},
|
||||||
@@ -168,6 +163,7 @@ let events: EventHandlers = {
|
|||||||
afterDestroy: {},
|
afterDestroy: {},
|
||||||
onPick: {},
|
onPick: {},
|
||||||
onDoubleClick: {},
|
onDoubleClick: {},
|
||||||
|
bindDataChange: {},
|
||||||
onKeyDown: {},
|
onKeyDown: {},
|
||||||
onKeyUp: {},
|
onKeyUp: {},
|
||||||
onPointerDown: {},
|
onPointerDown: {},
|
||||||
@@ -175,9 +171,7 @@ let events: EventHandlers = {
|
|||||||
onPointerMove: {},
|
onPointerMove: {},
|
||||||
onTouchStart: {},
|
onTouchStart: {},
|
||||||
onTouchEnd: {},
|
onTouchEnd: {},
|
||||||
};
|
});
|
||||||
// UUID 到事件的映射
|
|
||||||
const uuidEventMap: Map<string, Map<string, { name: string, fn: Function }[]>> = new Map();
|
|
||||||
|
|
||||||
export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
||||||
public container: HTMLElement;
|
public container: HTMLElement;
|
||||||
@@ -197,9 +191,27 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
public css2DRenderer: CSS2DRenderer = new CSS2DRenderer();
|
public css2DRenderer: CSS2DRenderer = new CSS2DRenderer();
|
||||||
public css3DRenderer: CSS3DRenderer = new CSS3DRenderer();
|
public css3DRenderer: CSS3DRenderer = new CSS3DRenderer();
|
||||||
public timer = new Timer();
|
public timer = new Timer();
|
||||||
|
public events: EventHandlers = createScriptEvents();
|
||||||
|
public uuidEventMap: Map<string, Map<string, { name: string, fn: Function }[]>> = new Map();
|
||||||
|
public fns: any = {
|
||||||
|
mousedown: null,
|
||||||
|
pointerdown: null,
|
||||||
|
pointerup: null,
|
||||||
|
pointermove: null,
|
||||||
|
keydown: null,
|
||||||
|
keyup: null,
|
||||||
|
touchstart: null,
|
||||||
|
dblclick: null,
|
||||||
|
};
|
||||||
//整个主场景的box3
|
//整个主场景的box3
|
||||||
public sceneBox3 = new THREE.Box3();
|
public sceneBox3 = new THREE.Box3();
|
||||||
public package: Package;
|
public package: Package;
|
||||||
|
private _pluginNeedsRender = false;
|
||||||
|
|
||||||
|
/** 供插件/内部模块请求下一帧强制渲染,传 true 标记,animate() 检测后自动清除 */
|
||||||
|
pluginRequestRender(value: boolean) {
|
||||||
|
this._pluginNeedsRender = value;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(options: IViewerSetting) {
|
constructor(options: IViewerSetting) {
|
||||||
super();
|
super();
|
||||||
@@ -362,6 +374,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
set enableScript(enable: boolean) {
|
set enableScript(enable: boolean) {
|
||||||
if (enable === this.enableScript) return;
|
if (enable === this.enableScript) return;
|
||||||
|
|
||||||
|
this.options.enableScript = enable;
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
this.installScripts();
|
this.installScripts();
|
||||||
} else {
|
} else {
|
||||||
@@ -591,14 +605,14 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
* 初始化事件监听
|
* 初始化事件监听
|
||||||
*/
|
*/
|
||||||
initEvent() {
|
initEvent() {
|
||||||
Fn.pointerdown = this.onPointerDown.bind(this);
|
this.fns.pointerdown = this.onPointerDown.bind(this);
|
||||||
this.container.addEventListener('pointerdown', Fn.pointerdown);
|
this.container.addEventListener('pointerdown', this.fns.pointerdown);
|
||||||
Fn.pointermove = this.onPointerMove.bind(this);
|
this.fns.pointermove = this.onPointerMove.bind(this);
|
||||||
this.container.addEventListener('pointermove', Fn.pointermove);
|
this.container.addEventListener('pointermove', this.fns.pointermove);
|
||||||
Fn.touchstart = this.onTouchStart.bind(this);
|
this.fns.touchstart = this.onTouchStart.bind(this);
|
||||||
this.container.addEventListener('touchstart', Fn.touchstart);
|
this.container.addEventListener('touchstart', this.fns.touchstart);
|
||||||
Fn.dblclick = this.onDoubleClick.bind(this)
|
this.fns.dblclick = this.onDoubleClick.bind(this)
|
||||||
this.container.addEventListener('dblclick', Fn.dblclick);
|
this.container.addEventListener('dblclick', this.fns.dblclick);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -606,9 +620,13 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
* @param uuids 传入此参数则仅组装此数组下Object.uuid的脚本
|
* @param uuids 传入此参数则仅组装此数组下Object.uuid的脚本
|
||||||
* @param filterName 传入此参数则仅组装此数组下Object.uuid的脚本中name匹配的脚本
|
* @param filterName 传入此参数则仅组装此数组下Object.uuid的脚本中name匹配的脚本
|
||||||
*/
|
*/
|
||||||
installScripts(uuids?: string | string[], filterName: string = "") {
|
installScripts(uuids?: string | string[], filterName: string = "", options: InstallScriptsOptions = {}) {
|
||||||
if (!this.enableScript) return;
|
if (!this.enableScript) return;
|
||||||
|
|
||||||
|
const { invokeLoaded = false, loadedScriptNames = [] } = options;
|
||||||
|
const loadedScriptNameSet = new Set(loadedScriptNames.filter(Boolean));
|
||||||
|
const shouldInvokeLoadedAfterInstall = invokeLoaded || loadedScriptNameSet.size > 0;
|
||||||
|
|
||||||
// 注册 Helper
|
// 注册 Helper
|
||||||
const helper = new ScriptHelper(this.scene);
|
const helper = new ScriptHelper(this.scene);
|
||||||
|
|
||||||
@@ -622,7 +640,7 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 拼接下方闭包函数返回的结构,即返回脚本中写的支持的事件函数
|
// 拼接下方闭包函数返回的结构,即返回脚本中写的支持的事件函数
|
||||||
const validEvents = Object.keys(events);
|
const validEvents = Object.keys(this.events);
|
||||||
|
|
||||||
// 准备返回结构
|
// 准备返回结构
|
||||||
validEvents.forEach(eventName => {
|
validEvents.forEach(eventName => {
|
||||||
@@ -646,7 +664,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
|
|
||||||
// 一个模型允许存在多个脚本
|
// 一个模型允许存在多个脚本
|
||||||
const scripts = App.scripts[uuid] || [];
|
const scripts = App.scripts[uuid] || [];
|
||||||
const uuidEvents = uuidEventMap.get(uuid) || new Map<string, { name: string, fn: Function }[]>();
|
const uuidEvents = this.uuidEventMap.get(uuid) || new Map<string, { name: string, fn: Function }[]>();
|
||||||
|
const scriptsNeedLoaded = new Set<string>();
|
||||||
|
|
||||||
scripts.forEach(script => {
|
scripts.forEach(script => {
|
||||||
// 如果存在需要按照name过滤
|
// 如果存在需要按照name过滤
|
||||||
@@ -660,6 +679,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
this.camera, this.modules.controls, this.timer, fns.render
|
this.camera, this.modules.controls, this.timer, fns.render
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shouldInvokeLoaded = loadedScriptNameSet.size > 0 ? loadedScriptNameSet.has(script.name) : invokeLoaded;
|
||||||
|
if (shouldInvokeLoaded && typeof functions.loaded === "function") {
|
||||||
|
scriptsNeedLoaded.add(script.name);
|
||||||
|
}
|
||||||
|
|
||||||
Object.entries(functions).forEach(([name, fn]) => {
|
Object.entries(functions).forEach(([name, fn]) => {
|
||||||
if (!fn || !validEvents.includes(name)) {
|
if (!fn || !validEvents.includes(name)) {
|
||||||
if (fn && !validEvents.includes(name)) {
|
if (fn && !validEvents.includes(name)) {
|
||||||
@@ -672,12 +696,16 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
const {type, target, ...params} = e;
|
const {type, target, ...params} = e;
|
||||||
|
|
||||||
// 点击事件只分发给对应模型
|
// 点击事件只分发给对应模型
|
||||||
if (["onPick", "onDoubleClick"].includes(name)) {
|
if (["onPick", "onDoubleClick", "bindDataChange"].includes(name)) {
|
||||||
const {intersect, object: _object} = params;
|
const {intersect, object: _object, data, config, index} = params;
|
||||||
|
|
||||||
if (_object.uuid !== object.uuid) return;
|
if (_object.uuid !== object.uuid) return;
|
||||||
|
|
||||||
(fn as Function).bind(object)(intersect as THREE.Intersection);
|
if (name === "bindDataChange") {
|
||||||
|
(fn as Function).bind(object)(data, config, index);
|
||||||
|
} else {
|
||||||
|
(fn as Function).bind(object)(intersect as THREE.Intersection);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isEmptyObject(params)) {
|
if (isEmptyObject(params)) {
|
||||||
(fn as Function).bind(object)();
|
(fn as Function).bind(object)();
|
||||||
@@ -688,8 +716,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加到全局事件集合
|
// 添加到全局事件集合
|
||||||
if (!events[name][uuid]) events[name][uuid] = [];
|
if (!this.events[name][uuid]) this.events[name][uuid] = [];
|
||||||
events[name][uuid].push(boundFn);
|
this.events[name][uuid].push(boundFn);
|
||||||
|
|
||||||
// 添加到 UUID 事件映射
|
// 添加到 UUID 事件映射
|
||||||
if (!uuidEvents.has(name)) uuidEvents.set(name, []);
|
if (!uuidEvents.has(name)) uuidEvents.set(name, []);
|
||||||
@@ -704,7 +732,13 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新 UUID 映射
|
// 更新 UUID 映射
|
||||||
uuidEventMap.set(uuid, uuidEvents);
|
this.uuidEventMap.set(uuid, uuidEvents);
|
||||||
|
|
||||||
|
if (shouldInvokeLoadedAfterInstall) {
|
||||||
|
scriptsNeedLoaded.forEach(scriptName => {
|
||||||
|
this.invokeInstalledScriptEvent(uuid, scriptName, "loaded");
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理指定 UUID 或全部
|
// 处理指定 UUID 或全部
|
||||||
@@ -714,15 +748,15 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
Object.keys(App.scripts).forEach(processUuid);
|
Object.keys(App.scripts).forEach(processUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Fn.keydown) {
|
if (!this.fns.keydown) {
|
||||||
Fn.keydown = (event: KeyboardEvent) => {
|
this.fns.keydown = (event: KeyboardEvent) => {
|
||||||
this.dispatchEvent({type: "onKeyDown", event})
|
this.dispatchEvent({type: "onKeyDown", event})
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', Fn.keydown);
|
window.addEventListener('keydown', this.fns.keydown);
|
||||||
Fn.keyup = (event: KeyboardEvent) => {
|
this.fns.keyup = (event: KeyboardEvent) => {
|
||||||
this.dispatchEvent({type: "onKeyUp", event})
|
this.dispatchEvent({type: "onKeyUp", event})
|
||||||
}
|
}
|
||||||
window.addEventListener('keyup', Fn.keyup);
|
window.addEventListener('keyup', this.fns.keyup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,9 +766,9 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
* @param filterName 传入此参数则仅卸载此Object.uuid的脚本中name匹配的脚本
|
* @param filterName 传入此参数则仅卸载此Object.uuid的脚本中name匹配的脚本
|
||||||
*/
|
*/
|
||||||
uninstallScriptsByUuid(uuid: string, filterName: string = "") {
|
uninstallScriptsByUuid(uuid: string, filterName: string = "") {
|
||||||
if (!uuidEventMap.has(uuid)) return;
|
if (!this.uuidEventMap.has(uuid)) return;
|
||||||
|
|
||||||
const uuidEvents = uuidEventMap.get(uuid)!;
|
const uuidEvents = this.uuidEventMap.get(uuid)!;
|
||||||
|
|
||||||
const uuidEventsArray = Array.from(uuidEvents);
|
const uuidEventsArray = Array.from(uuidEvents);
|
||||||
for (let i = uuidEventsArray.length - 1; i >= 0; i--) {
|
for (let i = uuidEventsArray.length - 1; i >= 0; i--) {
|
||||||
@@ -756,7 +790,7 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 全局事件集合
|
// 全局事件集合
|
||||||
const es = events[eventName][uuid];
|
const es = this.events[eventName][uuid];
|
||||||
// 移除相应函数
|
// 移除相应函数
|
||||||
const ei = es.findIndex(f => f === sc.fn);
|
const ei = es.findIndex(f => f === sc.fn);
|
||||||
if (ei !== -1) {
|
if (ei !== -1) {
|
||||||
@@ -764,40 +798,79 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (es.length === 0) {
|
if (es.length === 0) {
|
||||||
delete events[eventName][uuid];
|
delete this.events[eventName][uuid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 UUID 映射
|
// 清理 UUID 映射
|
||||||
if (Array.from(uuidEvents.keys()).length === 0) {
|
if (Array.from(uuidEvents.keys()).length === 0) {
|
||||||
uuidEventMap.delete(uuid);
|
this.uuidEventMap.delete(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invokeInstalledScriptEvent<T extends keyof ViewerEventMap>(
|
||||||
|
uuid: string,
|
||||||
|
scriptName: string,
|
||||||
|
eventName: T,
|
||||||
|
payload: Partial<ViewerEventMap[T]> = {}
|
||||||
|
): boolean {
|
||||||
|
const uuidEvents = this.uuidEventMap.get(uuid);
|
||||||
|
if (!uuidEvents) return false;
|
||||||
|
|
||||||
|
const handlers = uuidEvents.get(String(eventName));
|
||||||
|
if (!handlers || handlers.length === 0) return false;
|
||||||
|
|
||||||
|
let executed = false;
|
||||||
|
handlers.forEach(handler => {
|
||||||
|
if (handler.name !== scriptName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler.fn({
|
||||||
|
...payload,
|
||||||
|
type: eventName,
|
||||||
|
target: this,
|
||||||
|
});
|
||||||
|
executed = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
App.log.error(`[Script] 执行 ${scriptName}.${String(eventName)} 失败: ${message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return executed;
|
||||||
|
}
|
||||||
|
|
||||||
|
reinstallObjectScripts(uuid: string, loadedScriptNames: string[] = []): void {
|
||||||
|
if (!uuid) return;
|
||||||
|
|
||||||
|
const uniqueLoadedScriptNames = Array.from(new Set(loadedScriptNames.filter(Boolean)));
|
||||||
|
|
||||||
|
this.uninstallScriptsByUuid(uuid);
|
||||||
|
this.installScripts([uuid], "", uniqueLoadedScriptNames.length > 0 ? { loadedScriptNames: uniqueLoadedScriptNames } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卸载所有脚本
|
* 卸载所有脚本
|
||||||
*/
|
*/
|
||||||
unInstallScripts() {
|
unInstallScripts() {
|
||||||
if (this.enableScript) return;
|
|
||||||
|
|
||||||
// 直接遍历 UUID 映射,复杂度 O(n)
|
// 直接遍历 UUID 映射,复杂度 O(n)
|
||||||
uuidEventMap.forEach((_, uuid) => {
|
this.uuidEventMap.forEach((_, uuid) => {
|
||||||
this.uninstallScriptsByUuid(uuid);
|
this.uninstallScriptsByUuid(uuid);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 重置数据结构
|
// 重置数据结构
|
||||||
uuidEventMap.clear();
|
this.uuidEventMap.clear();
|
||||||
Object.keys(events).forEach(event => {
|
Object.keys(this.events).forEach(event => {
|
||||||
events[event] = {};
|
this.events[event] = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Fn.keydown) {
|
if (this.fns.keydown) {
|
||||||
window.removeEventListener('keydown', Fn.keydown);
|
window.removeEventListener('keydown', this.fns.keydown);
|
||||||
Fn.keydown = null;
|
this.fns.keydown = null;
|
||||||
|
|
||||||
window.removeEventListener('keyup', Fn.keyup);
|
window.removeEventListener('keyup', this.fns.keyup);
|
||||||
Fn.keyup = null;
|
this.fns.keyup = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,8 +977,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const array = getMousePosition(this.container, event.clientX, event.clientY);
|
const array = getMousePosition(this.container, event.clientX, event.clientY);
|
||||||
onDownPosition.fromArray(array);
|
onDownPosition.fromArray(array);
|
||||||
Fn.pointerup = this.onPointerUp.bind(this);
|
this.fns.pointerup = this.onPointerUp.bind(this);
|
||||||
document.addEventListener('pointerup', Fn.pointerup);
|
document.addEventListener('pointerup', this.fns.pointerup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -918,8 +991,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
const array = getMousePosition(this.container, event.clientX, event.clientY);
|
const array = getMousePosition(this.container, event.clientX, event.clientY);
|
||||||
onUpPosition.fromArray(array);
|
onUpPosition.fromArray(array);
|
||||||
this.handleClick();
|
this.handleClick();
|
||||||
document.removeEventListener('pointerup', Fn.pointerup);
|
document.removeEventListener('pointerup', this.fns.pointerup);
|
||||||
Fn.pointerup = null;
|
this.fns.pointerup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -940,8 +1013,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
const array = getMousePosition(this.container, touch.clientX, touch.clientY);
|
const array = getMousePosition(this.container, touch.clientX, touch.clientY);
|
||||||
onDownPosition.fromArray(array);
|
onDownPosition.fromArray(array);
|
||||||
Fn.pointerup = this.onTouchEnd.bind(this);
|
this.fns.pointerup = this.onTouchEnd.bind(this);
|
||||||
document.addEventListener('touchend', Fn.pointerup);
|
document.addEventListener('touchend', this.fns.pointerup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -955,8 +1028,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
const array = getMousePosition(this.container, touch.clientX, touch.clientY);
|
const array = getMousePosition(this.container, touch.clientX, touch.clientY);
|
||||||
onUpPosition.fromArray(array);
|
onUpPosition.fromArray(array);
|
||||||
this.handleClick();
|
this.handleClick();
|
||||||
document.removeEventListener('touchend', Fn.pointerup);
|
document.removeEventListener('touchend', this.fns.pointerup);
|
||||||
Fn.pointerup = null;
|
this.fns.pointerup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -994,6 +1067,9 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp});
|
this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp});
|
||||||
|
|
||||||
let needRender = App.animationManager.update(timeStamp);
|
let needRender = App.animationManager.update(timeStamp);
|
||||||
|
if (TWEEN.update()) {
|
||||||
|
needRender = true;
|
||||||
|
}
|
||||||
if (needRender) {
|
if (needRender) {
|
||||||
if (App.selected !== null && App.selected.animations.length > 0) {
|
if (App.selected !== null && App.selected.animations.length > 0) {
|
||||||
// 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb)
|
// 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb)
|
||||||
@@ -1037,6 +1113,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
needRender = true;
|
needRender = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._pluginNeedsRender) {
|
||||||
|
needRender = true;
|
||||||
|
this._pluginNeedsRender = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => {
|
this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => {
|
||||||
needRender = _needRender;
|
needRender = _needRender;
|
||||||
}
|
}
|
||||||
@@ -1133,14 +1214,14 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
|
|||||||
dispose() {
|
dispose() {
|
||||||
this.dispatchEvent({type: "beforeDestroy"});
|
this.dispatchEvent({type: "beforeDestroy"});
|
||||||
|
|
||||||
this.container.removeEventListener('mousedown', Fn.mousedown);
|
this.container.removeEventListener('mousedown', this.fns.mousedown);
|
||||||
Fn.mousedown = null;
|
this.fns.mousedown = null;
|
||||||
this.container.removeEventListener('pointermove', Fn.pointermove);
|
this.container.removeEventListener('pointermove', this.fns.pointermove);
|
||||||
Fn.pointermove = null;
|
this.fns.pointermove = null;
|
||||||
this.container.removeEventListener('touchstart', Fn.touchstart);
|
this.container.removeEventListener('touchstart', this.fns.touchstart);
|
||||||
Fn.touchstart = null;
|
this.fns.touchstart = null;
|
||||||
this.container.removeEventListener('dblclick', Fn.dblclick);
|
this.container.removeEventListener('dblclick', this.fns.dblclick);
|
||||||
Fn.dblclick = null;
|
this.fns.dblclick = null;
|
||||||
|
|
||||||
Object.keys(this.modules).forEach(key => {
|
Object.keys(this.modules).forEach(key => {
|
||||||
if (this.modules[key].dispose) {
|
if (this.modules[key].dispose) {
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import * as THREE from "three";
|
|||||||
import {RoomEnvironment} from "three/examples/jsm/environments/RoomEnvironment.js";
|
import {RoomEnvironment} from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||||
import {ShaderPass} from "three/examples/jsm/postprocessing/ShaderPass.js";
|
import {ShaderPass} from "three/examples/jsm/postprocessing/ShaderPass.js";
|
||||||
import {Effect} from "./Effect";
|
import {Effect} from "./Effect";
|
||||||
import {useAddSignal} from "@/hooks";
|
import {useAddSignal, useRemoveSignal} from "@/hooks";
|
||||||
import Viewer from "../Viewer";
|
import Viewer from "../Viewer";
|
||||||
import App from "@/core/app/App";
|
import App from "@/core/app/App";
|
||||||
import {focusObject} from "@/utils/scene/controls.ts";
|
import {focusObject} from "@/utils/scene/controls.ts";
|
||||||
|
|
||||||
|
type SignalListenerRecord = {
|
||||||
|
name: string;
|
||||||
|
listener: (...params: any[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export class Signals {
|
export class Signals {
|
||||||
private readonly viewer: Viewer;
|
private readonly viewer: Viewer;
|
||||||
|
|
||||||
private useBackgroundAsEnvironment = false;
|
private useBackgroundAsEnvironment = false;
|
||||||
|
private readonly signalListeners: SignalListenerRecord[] = [];
|
||||||
|
|
||||||
constructor(viewer:Viewer) {
|
constructor(viewer:Viewer) {
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
@@ -18,41 +24,46 @@ export class Signals {
|
|||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerSignal(name: string, listener: (...params: any[]) => void) {
|
||||||
|
this.signalListeners.push({ name, listener });
|
||||||
|
useAddSignal(name, listener);
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
useAddSignal("sceneCleared", this.sceneCleared.bind(this));
|
this.registerSignal("sceneCleared", this.sceneCleared.bind(this));
|
||||||
useAddSignal("transformModeChanged", this.transformModeChanged.bind(this));
|
this.registerSignal("transformModeChanged", this.transformModeChanged.bind(this));
|
||||||
useAddSignal("snapChanged", this.snapChanged.bind(this));
|
this.registerSignal("snapChanged", this.snapChanged.bind(this));
|
||||||
useAddSignal("spaceChanged", this.spaceChanged.bind(this));
|
this.registerSignal("spaceChanged", this.spaceChanged.bind(this));
|
||||||
useAddSignal("effectEnabledChange", this.effectEnabledChange.bind(this));
|
this.registerSignal("effectEnabledChange", this.effectEnabledChange.bind(this));
|
||||||
|
|
||||||
useAddSignal("rendererUpdated", this.rendererUpdated.bind(this));
|
this.registerSignal("rendererUpdated", this.rendererUpdated.bind(this));
|
||||||
useAddSignal("rendererCreated", this.rendererCreated.bind(this));
|
this.registerSignal("rendererCreated", this.rendererCreated.bind(this));
|
||||||
useAddSignal("rendererConfigUpdate", this.rendererConfigUpdate.bind(this));
|
this.registerSignal("rendererConfigUpdate", this.rendererConfigUpdate.bind(this));
|
||||||
useAddSignal("rendererDetectKTX2Support", this.rendererDetectKTX2Support.bind(this));
|
this.registerSignal("rendererDetectKTX2Support", this.rendererDetectKTX2Support.bind(this));
|
||||||
|
|
||||||
useAddSignal("sceneBackgroundChanged", this.sceneBackgroundChanged.bind(this));
|
this.registerSignal("sceneBackgroundChanged", this.sceneBackgroundChanged.bind(this));
|
||||||
useAddSignal("sceneEnvironmentChanged", this.sceneEnvironmentChanged.bind(this));
|
this.registerSignal("sceneEnvironmentChanged", this.sceneEnvironmentChanged.bind(this));
|
||||||
useAddSignal("sceneGraphChanged", this.sceneGraphChanged.bind(this));
|
this.registerSignal("sceneGraphChanged", this.sceneGraphChanged.bind(this));
|
||||||
useAddSignal("cameraChanged", this.cameraChanged.bind(this));
|
this.registerSignal("cameraChanged", this.cameraChanged.bind(this));
|
||||||
useAddSignal("cameraReset", this.viewer.updateAspectRatio.bind(this.viewer));
|
this.registerSignal("cameraReset", this.viewer.updateAspectRatio.bind(this.viewer));
|
||||||
useAddSignal("viewportCameraChanged", this.viewportCameraChanged.bind(this));
|
this.registerSignal("viewportCameraChanged", this.viewportCameraChanged.bind(this));
|
||||||
useAddSignal("viewportShadingChanged", this.viewportShadingChanged.bind(this));
|
this.registerSignal("viewportShadingChanged", this.viewportShadingChanged.bind(this));
|
||||||
|
|
||||||
useAddSignal("objectSelected", this.objectSelected.bind(this));
|
this.registerSignal("objectSelected", this.objectSelected.bind(this));
|
||||||
useAddSignal("objectFocused", this.objectFocused.bind(this));
|
this.registerSignal("objectFocused", this.objectFocused.bind(this));
|
||||||
useAddSignal("objectAdded", this.objectAdded.bind(this));
|
this.registerSignal("objectAdded", this.objectAdded.bind(this));
|
||||||
useAddSignal("objectChanged", this.objectChanged.bind(this));
|
this.registerSignal("objectChanged", this.objectChanged.bind(this));
|
||||||
useAddSignal("objectRemoved", this.objectRemoved.bind(this));
|
this.registerSignal("objectRemoved", this.objectRemoved.bind(this));
|
||||||
|
|
||||||
useAddSignal("geometryChanged", this.geometryChanged.bind(this));
|
this.registerSignal("geometryChanged", this.geometryChanged.bind(this));
|
||||||
useAddSignal("materialChanged", this.materialChanged.bind(this));
|
this.registerSignal("materialChanged", this.materialChanged.bind(this));
|
||||||
|
|
||||||
useAddSignal("sceneResize", this.sceneResize.bind(this));
|
this.registerSignal("sceneResize", this.sceneResize.bind(this));
|
||||||
useAddSignal("showGridChanged", this.showGridChanged.bind(this));
|
this.registerSignal("showGridChanged", this.showGridChanged.bind(this));
|
||||||
|
|
||||||
useAddSignal("scriptAdded",this.scriptAdded.bind(this));
|
this.registerSignal("scriptAdded",this.scriptAdded.bind(this));
|
||||||
useAddSignal("scriptRemoved",this.scriptRemoved.bind(this));
|
this.registerSignal("scriptRemoved",this.scriptRemoved.bind(this));
|
||||||
useAddSignal("scriptChanged",this.scriptChanged.bind(this));
|
this.registerSignal("scriptChanged",this.scriptChanged.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +77,8 @@ export class Signals {
|
|||||||
* 清空
|
* 清空
|
||||||
*/
|
*/
|
||||||
sceneCleared() {
|
sceneCleared() {
|
||||||
|
this.viewer.unInstallScripts();
|
||||||
|
|
||||||
this.viewer.modules.controls.setTarget(0, 0, 0,true);
|
this.viewer.modules.controls.setTarget(0, 0, 0,true);
|
||||||
this.viewer.pathtracer?.reset();
|
this.viewer.pathtracer?.reset();
|
||||||
|
|
||||||
@@ -453,15 +466,29 @@ export class Signals {
|
|||||||
/**
|
/**
|
||||||
* 添加脚本
|
* 添加脚本
|
||||||
*/
|
*/
|
||||||
scriptAdded(object:THREE.Object3D, _:ISceneScript){
|
scriptAdded(object:THREE.Object3D, sc:ISceneScript){
|
||||||
this.viewer.installScripts([object.uuid]);
|
if (!object?.uuid || !sc?.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.viewer.reinstallObjectScripts(object.uuid, [sc.name]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
App.log.error(`[Script] 热安装脚本 ${sc.name} 失败: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除脚本
|
* 移除脚本
|
||||||
*/
|
*/
|
||||||
scriptRemoved(object:THREE.Object3D, sc:ISceneScript){
|
scriptRemoved(object:THREE.Object3D, sc:ISceneScript){
|
||||||
this.viewer.uninstallScriptsByUuid(object.uuid,sc.name);
|
if (!object?.uuid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.viewer.reinstallObjectScripts(object.uuid);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
App.log.error(`[Script] 卸载脚本 ${sc?.name || "unknown"} 失败: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -469,8 +496,14 @@ export class Signals {
|
|||||||
*/
|
*/
|
||||||
scriptChanged(attributeName:string,object:THREE.Object3D, sc:ISceneScript){
|
scriptChanged(attributeName:string,object:THREE.Object3D, sc:ISceneScript){
|
||||||
if(attributeName !== "source") return;
|
if(attributeName !== "source") return;
|
||||||
|
if (!object?.uuid || !sc?.name) return;
|
||||||
|
|
||||||
this.viewer.installScripts([object.uuid],sc.name);
|
try {
|
||||||
|
this.viewer.reinstallObjectScripts(object.uuid, [sc.name]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
App.log.error(`[Script] 热更新脚本 ${sc.name} 失败: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -479,4 +512,11 @@ export class Signals {
|
|||||||
render(){
|
render(){
|
||||||
this.viewer.render();
|
this.viewer.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.signalListeners.forEach(({ name, listener }) => {
|
||||||
|
useRemoveSignal(name, listener);
|
||||||
|
});
|
||||||
|
this.signalListeners.length = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user